22 Commits

Author SHA1 Message Date
smueller
2bc0a19ada Merge branch 'dev'
All checks were successful
Docker Image bauen & veröffentlichen / build-and-push (push) Successful in 40s
2026-07-07 15:18:19 +02:00
smueller
9c75357150 Update login form to remove email placeholder
- Removed the placeholder text from the email input field in the login form for a cleaner user interface.
2026-07-07 15:17:55 +02:00
d3c6994d75 Merge pull request 'main' (#4) from main into dev
Reviewed-on: #4
2026-07-07 13:14:36 +00:00
smueller
55ec4e653b Enhance admin bootstrap process with reset functionality
- Update .env.example and README.md to include new ADMIN_BOOTSTRAP_RESET option for resetting admin credentials on startup.
- Modify main.py to implement logic for resetting the admin's password, role, and status if ADMIN_BOOTSTRAP_RESET is set to true.
- Adjust config.py to define the new setting, ensuring proper configuration management.
2026-07-07 15:09:44 +02:00
smueller
8b029ade77 Merge branch 'dev'
All checks were successful
Docker Image bauen & veröffentlichen / build-and-push (push) Successful in 43s
2026-07-07 14:50:53 +02:00
smueller
8b813c7e20 Update README.md to reflect new Docker image publishing and CI/CD workflow
- Enhance documentation with details on automatic image builds and pushes to Gitea Container Registry.
- Add instructions for pulling and running the container image, including usage in docker-compose.
- Include information on the CI/CD workflow for building and pushing images based on version tags.
2026-07-07 14:50:14 +02:00
smueller
9dd62999bd Remove diagnostic steps for registry login from Docker build workflow
- Eliminate previous checks for registry login credentials, including Basic-Auth and token request diagnostics.
- Streamline the workflow by focusing on essential steps for Gitea Container Registry login.
2026-07-07 14:47:25 +02:00
d7e3545a84 Merge pull request 'Enhance Docker build workflow with additional API tests for registry authentication' (#3) from dev into main
Reviewed-on: #3
2026-07-07 12:40:41 +00:00
smueller
e4150d0bc3 Enhance Docker build workflow with additional API tests for registry authentication
- Introduce new diagnostic steps to validate registry login credentials, including checks for Basic-Auth and token requests.
- Implement curl commands to retrieve and display authentication details, improving debugging capabilities during the Docker build process.
2026-07-07 14:40:20 +02:00
f24c173432 Merge pull request 'Add diagnostic step for registry login in Docker build workflow' (#2) from dev into main
Reviewed-on: #2
2026-07-07 12:38:21 +00:00
smueller
363232501e Add diagnostic step for registry login in Docker build workflow
- Introduce a new step to check registry login credentials, providing feedback on username and token status.
- Implement curl commands to test Basic-Auth and Bearer-Flow for improved debugging during the Docker build process.
2026-07-07 14:37:45 +02:00
6941dd44c3 Merge pull request 'Update Docker build workflow to use secrets for registry authentication' (#1) from dev into main
Reviewed-on: #1
2026-07-07 12:35:08 +00:00
smueller
5ce4555b4d Update Docker build workflow to use secrets for registry authentication
- Change username and password fields in the Docker login action to utilize secrets for enhanced security.
- Ensure compatibility with different registry user configurations by allowing fallback to the repository owner.
2026-07-07 14:34:36 +02:00
smueller
f4386f41b4 Update Docker build workflow to trigger only on version tags
- Modify the workflow to prevent builds on branch pushes (dev/main).
- Ensure builds are triggered exclusively when a version tag (v*) is set, aligning with release merges.
- Adjust image tagging logic to enable 'latest' tag only for tagged releases.
2026-07-07 14:28:19 +02:00
smueller
ef909ecbe0 Refactor newsletter generation and enhance user interface
Some checks failed
Docker Image bauen & veröffentlichen / build-and-push (push) Failing after 18s
- Update the newsletter generation logic to improve performance and maintainability.
- Revise the dashboard layout to streamline user interactions and enhance usability.
- Implement new input validation for newsletter fields to ensure data integrity.
- Adjust CSS styles for better visual consistency across the dashboard components.
2026-07-07 14:23:50 +02:00
smueller
654276f6b5 Enhance newsletter generation and dashboard functionality
- Introduce a new period selection feature in the dashboard for generating newsletters, allowing users to choose between "last month" and "last X days."
- Refactor newsletter generation logic to support filtering by article type (new, edited, or all) and improve the handling of filters in the dashboard.
- Update the dashboard template to include new input fields for period and article type, enhancing user experience.
- Improve CSS styles for send notifications and sections in the dashboard, providing clearer feedback on newsletter sending status.
2026-07-07 14:20:21 +02:00
smueller
f0a014a077 Refactor newsletter HTML generation for improved styling and consistency
- Update the "Top Highlights" section to use new color variables and enhanced padding for better visual appeal.
- Change the eyebrow text for article cards to uppercase for consistency with design standards.
- Adjust padding and margin styles in article cards to improve layout and readability in Outlook-compatible HTML.
2026-07-07 13:32:57 +02:00
smueller
83e6908f9f Enhance newsletter generation with editorial features
- Introduce optional fields for "Top Highlights" and "Editorial Note" in the newsletter dashboard, allowing editors to highlight key articles and provide additional context.
- Update the HTML and plain text generation functions to incorporate these new fields, ensuring they are displayed correctly in the newsletter output.
- Modify the dashboard template to include input areas for these new features, improving the user interface for newsletter creation.
2026-07-07 13:26:26 +02:00
smueller
b35c524ba5 Enhance CSRF protection and user management features
- Introduce CSRF token generation and management improvements, ensuring tokens are consistently retrieved from cookies or request state.
- Update user creation logic to handle validation errors more gracefully, providing user feedback for invalid input and existing email addresses.
- Revise user management UI to display success and error messages, improving user experience during user creation.
- Refactor admin user access checks to include additional role validation.
2026-07-03 14:54:14 +02:00
smueller
3f3223f17c Implement newsletter preview copy functionality and UI enhancements
- Add a new preview banner to indicate the live newsletter display.
- Introduce `copyFromPreviewFrame` function to enable copying the newsletter preview directly.
- Update copy status messages for clarity and improved user guidance.
- Revise button labels in the dashboard for better usability.
- Modify CSS for the newsletter preview frame and add styles for the new preview banner.
2026-07-03 13:48:51 +02:00
smueller
9f42cc77cc Enhance UI and functionality for admin dashboard and user management
- Update `_base_context` to include `active_nav` for dynamic navigation highlighting.
- Revamp `base.html` to improve header layout and navigation links for better user experience.
- Redesign `dashboard.html` to include statistics for new and edited articles, enhancing visibility of content changes.
- Improve `users.html` layout for user management, adding detailed user roles and status indicators.
- Update CSS styles for a cohesive design across the application, introducing new color variables and layout adjustments.
2026-07-03 13:44:57 +02:00
smueller
df4424fdf9 Enhance newsletter HTML generation and UI components
- Introduce helper functions `_outlook_font` and `_outlook_link` for better formatting in Outlook-compatible HTML.
- Update `create_outlook_html` to utilize new font and link functions, improving the overall appearance of the newsletter.
- Modify CSS to add styles for a new copy dialog overlay and related components.
- Refactor JavaScript to improve copy functionality and add a dialog for copying HTML content.
- Update dashboard template to enhance user instructions for copying and downloading HTML content.
2026-07-03 13:17:11 +02:00
17 changed files with 2570 additions and 773 deletions

View File

@@ -13,3 +13,8 @@ COOKIE_SECURE=false
# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy)
ADMIN_BOOTSTRAP_EMAIL=admin@internal.local
ADMIN_BOOTSTRAP_PASSWORD=ChangeMe123!
# true = setzt Passwort/Rolle/Status des Bootstrap-Admins bei jedem Start
# auf die obigen Werte zurueck (auch wenn der User schon existiert).
# Nach erfolgreichem Login wieder auf false setzen, damit UI-Passwortaenderungen
# nicht beim naechsten Neustart ueberschrieben werden.
ADMIN_BOOTSTRAP_RESET=false

View File

@@ -0,0 +1,57 @@
name: Docker Image bauen & veröffentlichen
on:
# Kein Build auf Branch-Pushes (dev/main).
# Build nur, wenn ein Versions-Tag (v*) gesetzt wird
# -> also beim Release-Merge dev -> main inkl. Tag.
push:
tags:
- "v*"
workflow_dispatch:
env:
# Host der Gitea Container Registry (bei Bedarf als Repo-Variable REGISTRY überschreiben)
REGISTRY: ${{ vars.REGISTRY || 'git.hexahost.dev' }}
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Repository auschecken
uses: actions/checkout@v4
- name: Docker Buildx einrichten
uses: docker/setup-buildx-action@v3
- name: An Gitea Container Registry anmelden
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER || github.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Metadaten (Tags & Labels) bestimmen
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,format=short
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- name: Image bauen & pushen
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -7,10 +7,16 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
- Login und Benutzerverwaltung mit RBAC (`admin`, `editor`, `reader`)
- Verpflichtender CSRF-Schutz auf allen POST-Formularen
- Filter in der Web-UI:
- Zeitraum in Tagen
- Nur bearbeitete Artikel
- Zeitraum in Tagen oder „Letzter Monat“
- Artikel-Auswahl: alle, nur neue oder nur bearbeitete Artikel
- Kategorie
- Redaktioneller Feinschliff pro Ausgabe:
- **Redaktionsnotiz** optionaler Hinweistext, der als eigene Box im Newsletter erscheint
- **Top-Highlights** bis zu 3 Artikel per Titel hervorheben (erscheinen oben als Highlight-Box)
- Datenquelle: MediaWiki API (`recentchanges` über `wikiDE/api.php`)
- E-Mail-Layout im Thomas-Krenn Corporate Design:
- Hero-Header mit Logo, Artikel-Karten mit „Zum Beitrag“-Button, Kontakt- und Adress-Footer
- Neu-/Bearbeitet-Trennung, Zusammenfassungs-Kacheln, Outlook-taugliche Kopiervorschau
- Export:
- Plain Text (für Outlook)
- Raw HTML
@@ -19,7 +25,7 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
- Verteilerlisten
- tägliche Zeitplanung
- Versandprotokoll
- Docker/Compose Betrieb
- Docker/Compose Betrieb, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag)
## Start mit Docker
@@ -44,6 +50,69 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
4. Zugriff:
- [http://localhost:8080/login](http://localhost:8080/login) (oder dein `HOST_PORT`)
## Container-Image (Gitea Packages)
Fertige Images werden automatisch in die Gitea Container Registry veröffentlicht:
```
git.hexahost.dev/smueller/tk-wiki-newsletter
```
Verfügbare Tags: `latest`, die Version (`X.Y.Z`, `X.Y`, `X`) sowie der kurze Commit-SHA.
Image ziehen und mit vorhandener `.env` starten:
```bash
docker login git.hexahost.dev
docker pull git.hexahost.dev/smueller/tk-wiki-newsletter:latest
docker run -d --name tk-newsletter-admin \
--env-file .env \
-p 8080:8000 \
-v newsletter_data:/app/data \
git.hexahost.dev/smueller/tk-wiki-newsletter:latest
```
Alternativ in `docker-compose.yml` statt `build: .` das Image referenzieren:
```yaml
services:
newsletter-admin:
image: git.hexahost.dev/smueller/tk-wiki-newsletter:latest
# build: . # <- nicht mehr nötig, wenn das fertige Image genutzt wird
```
### Automatischer Build (CI/CD)
Der Workflow `.gitea/workflows/docker-build.yml` baut und pusht das Image über Gitea Actions.
- **Auslöser**: nur beim Setzen eines Versions-Tags `v*` (kein Build bei normalen Pushes auf `dev`/`main`).
- **Release-Ablauf** (Build bei Merge `dev` → `main` mit Version):
```bash
git checkout main
git merge --no-ff dev
git push origin main
git tag v1.0.0
git push origin v1.0.0 # <- startet Build & Push
```
- **Benötigte Repo-Secrets** (Settings → Actions → Secrets):
- `REGISTRY_TOKEN`: Gitea Access-Token mit Scope **Package: Read and write**
- `REGISTRY_USER` (optional): Registry-Benutzername, falls abweichend vom Repo-Owner
- **Optionale Repo-Variable**: `REGISTRY`, um den Registry-Host zu überschreiben (Default `git.hexahost.dev`).
## Admin-Login & Passwort zurücksetzen
- Das Login erfolgt über **E-Mail + Passwort** (kein separater Benutzername). Beim ersten Start wird der Admin aus `ADMIN_BOOTSTRAP_EMAIL` / `ADMIN_BOOTSTRAP_PASSWORD` angelegt.
- **Wichtig:** Existiert der Admin bereits (persistente DB im Volume `newsletter_data`), wird eine spätere Passwort-Änderung in der `.env` normalerweise **nicht** übernommen.
- Um Passwort/Rolle/Status auf die `.env`-Werte zurückzusetzen: `ADMIN_BOOTSTRAP_RESET=true` setzen und Container neu starten:
```bash
docker compose up -d --force-recreate
```
Nach erfolgreichem Login `ADMIN_BOOTSTRAP_RESET=false` setzen, damit UI-Passwortänderungen nicht bei jedem Neustart überschrieben werden.
## Sicherheits-Hinweise für Produktion
- Reverse Proxy (z. B. Nginx/Traefik) mit TLS vor den Container setzen.

View File

@@ -13,6 +13,9 @@ class Settings(BaseSettings):
cookie_secure: bool = False
admin_bootstrap_email: str = "admin@internal.local"
admin_bootstrap_password: str = "ChangeMe123!"
# Wenn true: setzt Passwort/Rolle/Status des Bootstrap-Admins beim Start
# auf die .env-Werte zurück (auch wenn der User bereits existiert).
admin_bootstrap_reset: bool = False
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

View File

@@ -1,5 +1,7 @@
from pathlib import Path
from pydantic import ValidationError
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, status
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
@@ -17,7 +19,7 @@ from app.models.user import User
from app.schemas.user import UserCreate
from app.services.auth import get_admin_user, get_current_user, get_optional_user, require_editor_or_admin
from app.services.config_store import get_config, set_config
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, validate_csrf
from app.services.csrf import CSRF_COOKIE_NAME, ensure_csrf_cookie, generate_csrf_token, validate_csrf
from app.services.newsletter import (
DEFAULT_DISPLAY,
article_url,
@@ -27,6 +29,8 @@ from app.services.newsletter import (
create_subject,
filter_articles,
parse_display_options,
parse_highlights,
resolve_period,
split_articles_by_type,
)
from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter
@@ -62,13 +66,29 @@ def _ensure_schema_upgrades() -> None:
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
request.state.csrf_token = cookie_token or generate_csrf_token()
response: Response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self'; script-src 'self';"
if not request.cookies.get(CSRF_COOKIE_NAME):
response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), httponly=True, secure=cookie_secure(request), samesite="strict")
# Die Newsletter-Vorschau läuft in einem srcdoc-iframe und erbt diese CSP.
# E-Mail-HTML benötigt zwingend Inline-Styles; das Logo liegt auf einem externen https-Host.
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"img-src 'self' https: data:; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self';"
)
if not cookie_token:
response.set_cookie(
CSRF_COOKIE_NAME,
request.state.csrf_token,
httponly=True,
secure=cookie_secure(request),
samesite="strict",
)
return response
@@ -77,6 +97,12 @@ def _bootstrap_admin() -> None:
try:
existing = db.query(User).filter(User.email == settings.admin_bootstrap_email).first()
if existing:
if settings.admin_bootstrap_reset:
existing.password_hash = hash_password(settings.admin_bootstrap_password)
existing.is_admin = True
existing.role = "admin"
existing.is_active = True
db.commit()
return
user = User(
email=settings.admin_bootstrap_email,
@@ -93,10 +119,15 @@ def _bootstrap_admin() -> None:
def _base_context(request: Request, user: User, db: Session) -> dict:
path = request.url.path
active_nav = "dashboard"
if path.startswith("/admin/users"):
active_nav = "users"
return {
"user": user,
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"csrf_token": ensure_csrf_cookie(request),
"smtp": get_smtp_settings(db),
"active_nav": active_nav,
}
@@ -133,7 +164,7 @@ def login_page(request: Request, db: Session = Depends(get_db)):
request,
"login.html",
{
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"csrf_token": ensure_csrf_cookie(request),
"error_message": None,
},
)
@@ -154,7 +185,7 @@ def login(
request,
"login.html",
{
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"csrf_token": ensure_csrf_cookie(request),
"error_message": "Sitzung abgelaufen. Bitte erneut anmelden.",
},
)
@@ -164,7 +195,7 @@ def login(
request,
"login.html",
{
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request),
"csrf_token": ensure_csrf_cookie(request),
"error_message": "Ungültige Zugangsdaten.",
},
)
@@ -182,6 +213,63 @@ def logout(request: Request, csrf_token: str = Form(...)):
return response
def _empty_filters() -> dict:
return {
"days": 30,
"period": "days",
"article_type": "all",
"category": "",
"display": DEFAULT_DISPLAY,
"editor_tip": "",
"highlights": "",
}
async def _generate_newsletter(
*,
period_mode: str,
days: int,
article_type: str,
category: str,
display,
editor_tip: str,
highlight_list: list[str],
) -> dict:
p = resolve_period(period_mode, days)
result = {
"period": p,
"wiki_error": None,
"articles": [],
"articles_new": [],
"articles_edited": [],
"plain_text": "",
"raw_html": "",
"outlook_html": "",
"subject": "",
}
try:
articles = await wiki_service.get_recent_changes(
days=p["days"], article_type=article_type, start=p["start"], end=p["end"]
)
filtered = filter_articles(articles, category_filter=category or None)
new_articles, edited_articles = split_articles_by_type(filtered)
prange = (p["start_str"], p["end_str"])
result.update(
{
"articles": filtered,
"articles_new": new_articles,
"articles_edited": edited_articles,
"subject": create_subject(p["days"], category, p["label"]),
"plain_text": create_plain_text(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type),
"raw_html": create_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type),
"outlook_html": create_outlook_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type),
}
)
except WikiFetchError as exc:
result["wiki_error"] = exc.message
return result
@app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
context = _base_context(request, current_user, db)
@@ -192,13 +280,10 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use
"raw_html": "",
"outlook_html": "",
"plain_text": "",
"filters": {
"days": 30,
"only_edited": False,
"category": "",
"display": DEFAULT_DISPLAY,
},
"subject": "",
"filters": _empty_filters(),
"wiki_error": None,
"send_result": None,
})
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
context["send_logs"] = logs
@@ -209,86 +294,132 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use
async def dashboard_generate(
request: Request,
csrf_token: str = Form(...),
period: str = Form("days"),
days: int = Form(30),
only_edited: str | None = Form(None),
article_type: str = Form("all"),
category: str = Form(""),
show_date: str | None = Form(None),
show_user: str | None = Form(None),
show_category: str | None = Form(None),
editor_tip: str = Form(""),
highlights: str = Form(""),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin),
):
validate_csrf(request, csrf_token)
days = max(1, min(days, 90))
only_edited_flag = only_edited == "true"
if article_type not in ("all", "new", "edited"):
article_type = "all"
display = parse_display_options(show_date, show_user, show_category)
wiki_error = None
filtered: list = []
articles_new: list = []
articles_edited: list = []
plain_text = ""
raw_html = ""
outlook_html = ""
try:
articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag)
filtered = filter_articles(articles, category_filter=category or None)
articles_new, articles_edited = split_articles_by_type(filtered)
title = create_subject(days, category)
plain_text = create_plain_text(filtered, days, display, category)
raw_html = create_html(filtered, days, display, category)
outlook_html = create_outlook_html(filtered, days, display, category)
run_scheduled_send_if_due(db, title, raw_html, plain_text)
except WikiFetchError as exc:
wiki_error = exc.message
highlight_list = parse_highlights(highlights)
gen = await _generate_newsletter(
period_mode=period,
days=days,
article_type=article_type,
category=category,
display=display,
editor_tip=editor_tip,
highlight_list=highlight_list,
)
if not gen["wiki_error"]:
run_scheduled_send_if_due(db, gen["subject"], gen["raw_html"], gen["plain_text"])
context = _base_context(request, current_user, db)
context.update(
{
"articles": filtered,
"articles_new": articles_new,
"articles_edited": articles_edited,
"raw_html": raw_html,
"outlook_html": outlook_html,
"plain_text": plain_text,
"articles": gen["articles"],
"articles_new": gen["articles_new"],
"articles_edited": gen["articles_edited"],
"raw_html": gen["raw_html"],
"outlook_html": gen["outlook_html"],
"plain_text": gen["plain_text"],
"subject": gen["subject"],
"filters": {
"days": days,
"only_edited": only_edited_flag,
"period": period,
"article_type": article_type,
"category": category,
"display": display,
"editor_tip": editor_tip,
"highlights": highlights,
},
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
"wiki_error": wiki_error,
"wiki_error": gen["wiki_error"],
"send_result": None,
}
)
return _render(request, "dashboard.html", context)
@app.post("/newsletter/send-now")
@app.post("/newsletter/send-now", response_class=HTMLResponse)
async def send_now(
request: Request,
csrf_token: str = Form(...),
period: str = Form("days"),
days: int = Form(30),
only_edited: str | None = Form(None),
article_type: str = Form("all"),
category: str = Form(""),
show_date: str | None = Form(None),
show_user: str | None = Form(None),
show_category: str | None = Form(None),
editor_tip: str = Form(""),
highlights: str = Form(""),
db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin),
):
validate_csrf(request, csrf_token)
display = parse_display_options(show_date, show_user, show_category)
only_edited_flag = only_edited == "true"
try:
articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited_flag)
except WikiFetchError as exc:
raise HTTPException(status_code=502, detail=exc.message) from exc
filtered = filter_articles(articles, category_filter=category or None)
title = create_subject(max(1, min(days, 90)), category)
plain_text = create_plain_text(filtered, max(1, min(days, 90)), display, category)
raw_html = create_html(filtered, max(1, min(days, 90)), display, category)
if article_type not in ("all", "new", "edited"):
article_type = "all"
highlight_list = parse_highlights(highlights)
days = max(1, min(days, 90))
gen = await _generate_newsletter(
period_mode=period,
days=days,
article_type=article_type,
category=category,
display=display,
editor_tip=editor_tip,
highlight_list=highlight_list,
)
send_result: dict[str, str]
if gen["wiki_error"]:
send_result = {"status": "failed", "message": f"Versand abgebrochen Wiki-Fehler: {gen['wiki_error']}"}
elif not gen["articles"]:
send_result = {"status": "failed", "message": "Versand abgebrochen im Zeitraum wurden keine Artikel gefunden."}
else:
recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()]
send_newsletter(db, title, raw_html, plain_text, recipients, scheduled=False)
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False)
send_result = {"status": status_code, "message": detail}
context = _base_context(request, current_user, db)
context.update(
{
"articles": gen["articles"],
"articles_new": gen["articles_new"],
"articles_edited": gen["articles_edited"],
"raw_html": gen["raw_html"],
"outlook_html": gen["outlook_html"],
"plain_text": gen["plain_text"],
"subject": gen["subject"],
"filters": {
"days": days,
"period": period,
"article_type": article_type,
"category": category,
"display": display,
"editor_tip": editor_tip,
"highlights": highlights,
},
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
"wiki_error": gen["wiki_error"],
"send_result": send_result,
}
)
return _render(request, "dashboard.html", context)
@app.post("/admin/smtp")
@@ -320,14 +451,41 @@ def update_smtp_settings(
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
@app.get("/admin/users", response_class=HTMLResponse)
def users_page(request: Request, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
def _users_context(request: Request, admin: User, db: Session, **extra) -> dict:
context = _base_context(request, admin, db)
context["users"] = db.query(User).order_by(User.created_at.desc()).all()
return _render(request, "users.html", context)
context.setdefault("error_message", None)
context.setdefault("success_message", None)
context.update(extra)
return context
@app.post("/admin/users")
def _format_validation_error(exc: ValidationError) -> str:
messages: list[str] = []
for err in exc.errors():
msg = err.get("msg", "Ungültige Eingabe")
if msg.startswith("Value error, "):
msg = msg[13:]
messages.append(msg)
return " ".join(messages) if messages else "Ungültige Eingabe."
@app.get("/admin/users", response_class=HTMLResponse)
def users_page(request: Request, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
created = request.query_params.get("created") == "1"
return _render(
request,
"users.html",
_users_context(
request,
admin,
db,
success_message="Benutzer wurde erfolgreich angelegt." if created else None,
),
)
@app.post("/admin/users", response_class=HTMLResponse)
def create_user(
request: Request,
csrf_token: str = Form(...),
@@ -338,11 +496,32 @@ def create_user(
db: Session = Depends(get_db),
admin: User = Depends(get_admin_user),
):
try:
validate_csrf(request, csrf_token)
payload = UserCreate(email=email, full_name=full_name, password=password, role=role)
except HTTPException:
return _render(
request,
"users.html",
_users_context(request, admin, db, error_message="Sitzung abgelaufen. Bitte Seite neu laden und erneut versuchen."),
)
try:
payload = UserCreate(email=email.strip(), full_name=full_name.strip(), password=password, role=role)
except ValidationError as exc:
return _render(
request,
"users.html",
_users_context(request, admin, db, error_message=_format_validation_error(exc)),
)
existing = db.query(User).filter(User.email == payload.email).first()
if existing:
raise HTTPException(status_code=400, detail="E-Mail existiert bereits.")
return _render(
request,
"users.html",
_users_context(request, admin, db, error_message="Diese E-Mail-Adresse ist bereits registriert."),
)
user = User(
email=payload.email,
full_name=payload.full_name,
@@ -353,4 +532,4 @@ def create_user(
)
db.add(user)
db.commit()
return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND)
return RedirectResponse(url="/admin/users?created=1", status_code=status.HTTP_302_FOUND)

View File

@@ -36,7 +36,7 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != "admin":
if current_user.role != "admin" and not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
return current_user

View File

@@ -7,13 +7,21 @@ CSRF_COOKIE_NAME = "csrf_token"
CSRF_FORM_FIELD = "csrf_token"
def ensure_csrf_cookie(request: Request) -> str:
token = request.cookies.get(CSRF_COOKIE_NAME)
if token:
return token
def generate_csrf_token() -> str:
return secrets.token_urlsafe(32)
def ensure_csrf_cookie(request: Request) -> str:
"""Liefert das CSRF-Token für die aktuelle Anfrage (Cookie oder request.state)."""
state_token = getattr(request.state, "csrf_token", None)
if state_token:
return state_token
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
if cookie_token:
return cookie_token
return generate_csrf_token()
def validate_csrf(request: Request, form_token: str) -> None:
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
if not cookie_token or not form_token or not secrets.compare_digest(cookie_token, form_token):

File diff suppressed because it is too large Load Diff

View File

@@ -32,14 +32,16 @@ def send_newsletter(
text_content: str,
recipients: Sequence[str],
scheduled: bool = False,
) -> None:
) -> tuple[str, str]:
settings = get_smtp_settings(db)
if settings["enabled"].lower() != "true":
_log(db, subject, recipients, "skipped", "SMTP ist deaktiviert.", scheduled)
return
detail = "SMTP ist deaktiviert. Bitte in der SMTP-Konfiguration aktivieren."
_log(db, subject, recipients, "skipped", detail, scheduled)
return "skipped", detail
if not settings["host"] or not settings["from_email"] or not recipients:
_log(db, subject, recipients, "failed", "SMTP unvollständig konfiguriert.", scheduled)
return
detail = "SMTP unvollständig konfiguriert (Host, Absender oder Empfänger fehlen)."
_log(db, subject, recipients, "failed", detail, scheduled)
return "failed", detail
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(text_content, "plain", "utf-8"))
@@ -56,9 +58,13 @@ def send_newsletter(
if settings["username"]:
server.login(settings["username"], settings["password"])
server.sendmail(settings["from_email"], list(recipients), msg.as_string())
_log(db, subject, recipients, "sent", "Versand erfolgreich.", scheduled)
detail = f"Versand erfolgreich an {len(recipients)} Empfänger."
_log(db, subject, recipients, "sent", detail, scheduled)
return "sent", detail
except Exception as exc:
_log(db, subject, recipients, "failed", f"Versandfehler: {exc}", scheduled)
detail = f"Versandfehler: {exc}"
_log(db, subject, recipients, "failed", detail, scheduled)
return "failed", detail
def run_scheduled_send_if_due(db: Session, subject: str, html_content: str, text_content: str) -> None:

View File

@@ -19,11 +19,18 @@ class WikiService:
def __init__(self) -> None:
self.api_url = settings.wiki_api_url
async def get_recent_changes(self, days: int = 30, only_edited: bool = False) -> list[dict[str, Any]]:
now = datetime.now(timezone.utc)
start = now - timedelta(days=days)
rcstart = now.strftime("%Y-%m-%dT%H:%M:%SZ")
rcend = start.strftime("%Y-%m-%dT%H:%M:%SZ")
async def get_recent_changes(
self,
days: int = 30,
article_type: str = "all",
start: datetime | None = None,
end: datetime | None = None,
) -> list[dict[str, Any]]:
# MediaWiki listet neueste Änderungen zuerst: rcstart = obere (neuere) Grenze, rcend = untere (ältere).
upper = end or datetime.now(timezone.utc)
lower = start or (upper - timedelta(days=days))
rcstart = upper.strftime("%Y-%m-%dT%H:%M:%SZ")
rcend = lower.strftime("%Y-%m-%dT%H:%M:%SZ")
params: dict[str, str] = {
"action": "query",
@@ -35,7 +42,9 @@ class WikiService:
"rcend": rcend,
"rcnamespace": "0",
}
if only_edited:
if article_type == "new":
params["rctype"] = "new"
elif article_type == "edited":
params["rctype"] = "edit"
else:
params["rctype"] = "new|edit"

View File

@@ -1,26 +1,709 @@
:root {
--tk-orange: #ED6B06;
--tk-orange-dark: #C95605;
--tk-orange-light: #FFF4ED;
--tk-gray-dark: #4A4A4A;
--tk-gray: #6B6B6B;
--tk-gray-light: #F5F5F5;
--tk-border: #E0E0E0;
--tk-text: #1A1A1A;
--tk-white: #FFFFFF;
--tk-success: #027A48;
--tk-success-bg: #ECFDF3;
--tk-error: #B42318;
--tk-error-bg: #FEF3F2;
--tk-warning: #B54708;
--tk-warning-bg: #FFFAEB;
--shadow-sm: 0 1px 3px rgba(74, 74, 74, 0.08);
--shadow-md: 0 4px 16px rgba(74, 74, 74, 0.1);
--radius: 8px;
--radius-lg: 12px;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: Arial, sans-serif; background: #f5f7fb; color: #1f2430; }
.topbar { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; background: #0c2c5a; color: #fff; }
.container { max-width: 1100px; margin: 1rem auto; padding: 0 1rem; }
.card { background: #fff; border: 1px solid #dde3ef; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.narrow { max-width: 420px; margin: 2rem auto; }
.form-grid { display: grid; gap: 0.75rem; }
label { display: grid; gap: 0.3rem; font-weight: 600; }
input, button, textarea { font: inherit; padding: 0.55rem; }
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
background: var(--tk-gray-light);
color: var(--tk-text);
line-height: 1.5;
min-height: 100vh;
}
a { color: var(--tk-orange); text-decoration: none; }
a:hover { color: var(--tk-orange-dark); text-decoration: underline; }
/* ── Header ── */
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.85rem 1.5rem;
background: var(--tk-gray-dark);
color: var(--tk-white);
border-bottom: 4px solid var(--tk-orange);
}
.topbar-brand {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.topbar-logo {
width: 36px;
height: 36px;
background: var(--tk-orange);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.1rem;
flex-shrink: 0;
}
.topbar h1 {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
line-height: 1.2;
}
.topbar-sub {
margin: 0;
font-size: 0.78rem;
color: #BBBBBB;
font-weight: normal;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.topbar-nav {
display: flex;
gap: 0.25rem;
}
.topbar-nav a {
color: #DDDDDD;
padding: 0.35rem 0.65rem;
border-radius: 4px;
font-size: 0.88rem;
text-decoration: none;
}
.topbar-nav a:hover,
.topbar-nav a.active {
background: rgba(255, 255, 255, 0.1);
color: var(--tk-white);
text-decoration: none;
}
.user-chip {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #CCCCCC;
}
.topbar .btn-logout {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.35);
color: var(--tk-white);
padding: 0.35rem 0.75rem;
font-size: 0.85rem;
}
.topbar .btn-logout:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
/* ── Layout ── */
.container {
max-width: 1140px;
margin: 0 auto;
padding: 1.5rem 1rem 2.5rem;
}
.page-header {
margin-bottom: 1.5rem;
}
.page-header h2 {
margin: 0 0 0.35rem;
font-size: 1.65rem;
color: var(--tk-gray-dark);
}
.page-lead {
margin: 0;
color: var(--tk-gray);
font-size: 0.95rem;
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--tk-white);
border: 1px solid var(--tk-border);
border-radius: var(--radius);
padding: 1rem 1.15rem;
box-shadow: var(--shadow-sm);
}
.stat-card.accent-new { border-left: 4px solid var(--tk-orange); }
.stat-card.accent-edit { border-left: 4px solid var(--tk-gray-dark); }
.stat-card.accent-total { border-left: 4px solid var(--tk-gray); }
.stat-value {
font-size: 2rem;
font-weight: 700;
line-height: 1.1;
color: var(--tk-gray-dark);
}
.stat-card.accent-new .stat-value { color: var(--tk-orange); }
.stat-label {
margin-top: 0.25rem;
font-size: 0.85rem;
color: var(--tk-gray);
}
.layout-2col {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1rem;
align-items: start;
}
@media (max-width: 900px) {
.layout-2col { grid-template-columns: 1fr; }
.topbar { flex-wrap: wrap; }
}
/* ── Cards ── */
.card {
background: var(--tk-white);
border: 1px solid var(--tk-border);
border-radius: var(--radius-lg);
padding: 1.25rem 1.35rem;
margin-bottom: 1rem;
box-shadow: var(--shadow-sm);
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
padding-bottom: 0.85rem;
border-bottom: 1px solid var(--tk-border);
}
.card-header h2,
.card-header h3,
.card > h2,
.card > h3 {
margin: 0;
color: var(--tk-gray-dark);
font-size: 1.1rem;
}
.card > h2 { font-size: 1.2rem; margin-bottom: 1rem; }
.card-desc {
margin: 0.35rem 0 0;
font-size: 0.88rem;
color: var(--tk-gray);
font-weight: normal;
}
.card-accent-orange { border-top: 3px solid var(--tk-orange); }
.card-accent-gray { border-top: 3px solid var(--tk-gray-dark); }
.sidebar-card {
background: var(--tk-orange-light);
border: 1px solid #F5D5BC;
border-radius: var(--radius);
padding: 1rem;
margin-bottom: 0.75rem;
font-size: 0.88rem;
}
.sidebar-card h4 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
color: var(--tk-gray-dark);
}
.sidebar-card ul {
margin: 0;
padding-left: 1.1rem;
color: var(--tk-gray);
}
.sidebar-card li { margin-bottom: 0.3rem; }
.narrow { max-width: 420px; margin: 3rem auto; }
/* ── Forms ── */
.form-grid { display: grid; gap: 0.85rem; }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
}
@media (max-width: 600px) {
.form-row { grid-template-columns: 1fr; }
}
label {
display: grid;
gap: 0.35rem;
font-weight: 600;
font-size: 0.88rem;
color: var(--tk-gray-dark);
}
input, select, textarea, button {
font: inherit;
}
input, select, textarea {
padding: 0.6rem 0.75rem;
border: 1px solid var(--tk-border);
border-radius: 6px;
background: var(--tk-white);
color: var(--tk-text);
transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--tk-orange);
box-shadow: 0 0 0 3px rgba(237, 107, 6, 0.15);
}
textarea { width: 100%; resize: vertical; }
button { width: fit-content; background: #1b5dbf; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
.btn-secondary { background: #334155; }
.copy-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }
.copy-status { font-size: 0.92rem; color: #64748b; }
.copy-status.success { color: #027a48; }
.copy-status.error { color: #b42318; }
.newsletter-preview-frame { width: 100%; min-height: 720px; border: 1px solid #D7DEE8; border-radius: 6px; background: #EEF2F7; box-shadow: 0 2px 8px rgba(0, 51, 102, 0.08); }
.card h3 { color: #003366; margin-top: 0; }
.checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
cursor: pointer;
}
.checkbox input[type="checkbox"] {
width: 1rem;
height: 1rem;
accent-color: var(--tk-orange);
cursor: pointer;
}
.display-options {
border: 1px solid var(--tk-border);
border-radius: var(--radius);
padding: 0.85rem 1rem;
display: grid;
gap: 0.5rem;
background: var(--tk-gray-light);
}
.display-options legend {
font-weight: 600;
padding: 0 0.35rem;
color: var(--tk-gray-dark);
font-size: 0.88rem;
}
/* ── Buttons ── */
button {
width: fit-content;
background: var(--tk-orange);
color: var(--tk-white);
border: 0;
border-radius: 6px;
cursor: pointer;
padding: 0.6rem 1.1rem;
font-weight: 600;
font-size: 0.9rem;
transition: background 0.15s, transform 0.1s;
}
button:hover { background: var(--tk-orange-dark); }
button:active { transform: scale(0.98); }
.btn-primary {
background: var(--tk-orange);
padding: 0.7rem 1.4rem;
font-size: 0.95rem;
}
.btn-block { width: 100%; }
.btn-secondary {
background: var(--tk-gray-dark);
}
.btn-secondary:hover { background: #3A3A3A; }
.btn-outline {
background: transparent;
border: 1px solid var(--tk-border);
color: var(--tk-gray-dark);
}
.btn-outline:hover {
background: var(--tk-gray-light);
border-color: var(--tk-gray);
}
.btn-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
/* ── Badges ── */
.badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.badge-admin { background: var(--tk-orange-light); color: var(--tk-orange-dark); }
.badge-editor { background: #EEEEEE; color: var(--tk-gray-dark); }
.badge-reader { background: var(--tk-gray-light); color: var(--tk-gray); }
.badge-sent { background: var(--tk-success-bg); color: var(--tk-success); }
.badge-failed { background: var(--tk-error-bg); color: var(--tk-error); }
.badge-skipped { background: var(--tk-warning-bg); color: var(--tk-warning); }
.count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
padding: 0.15rem 0.5rem;
background: var(--tk-gray-light);
border: 1px solid var(--tk-border);
border-radius: 999px;
font-size: 0.82rem;
font-weight: 600;
color: var(--tk-gray-dark);
}
.count-badge.orange {
background: var(--tk-orange-light);
border-color: #F5D5BC;
color: var(--tk-orange-dark);
}
/* ── Tables ── */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th {
background: var(--tk-gray-light);
color: var(--tk-gray-dark);
font-weight: 600;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.65rem 0.75rem;
text-align: left;
border-bottom: 2px solid var(--tk-border);
}
td {
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--tk-border);
vertical-align: top;
}
tbody tr:hover { background: #FAFAFA; }
.empty-row td {
color: var(--tk-gray);
font-style: italic;
text-align: center;
padding: 1.5rem;
}
/* ── Preview & Copy ── */
.copy-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.85rem;
background: var(--tk-gray-light);
border-radius: var(--radius);
border: 1px solid var(--tk-border);
}
.copy-status {
font-size: 0.88rem;
color: var(--tk-gray);
flex: 1 1 100%;
margin-top: 0.25rem;
}
@media (min-width: 700px) {
.copy-status { flex: 1 1 auto; margin-top: 0; margin-left: 0.5rem; }
}
.copy-status.success { color: var(--tk-success); }
.copy-status.error { color: var(--tk-error); }
.preview-banner {
margin-top: 0.5rem;
padding: 0.5rem 0.85rem;
background: var(--tk-orange-light);
border: 1px solid #F5D5BC;
border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
font-size: 0.82rem;
font-weight: 600;
color: var(--tk-orange-dark);
text-align: center;
}
.newsletter-preview-frame {
width: 100%;
min-height: 640px;
border: 1px solid var(--tk-border);
border-radius: 0 0 var(--radius) var(--radius);
background: #FFFFFF;
box-shadow: var(--shadow-md);
display: block;
}
.send-section {
margin-top: 1.25rem;
padding: 1rem 1.1rem;
border: 1px solid var(--tk-border);
border-left: 4px solid var(--tk-orange);
border-radius: var(--radius);
background: var(--tk-gray-light);
}
.send-banner {
margin-top: 1rem;
padding: 0.75rem 1rem;
border-radius: var(--radius);
font-size: 0.9rem;
font-weight: 600;
border: 1px solid transparent;
}
.send-banner-sent {
background: #E7F5EC;
color: #1E7A43;
border-color: #B7E0C6;
}
.send-banner-failed {
background: #FDEAEA;
color: #B42318;
border-color: #F5C2C0;
}
.send-banner-skipped {
background: var(--tk-orange-light);
color: var(--tk-orange-dark);
border-color: #F5C9A6;
}
.textarea-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.readonly-output {
font-family: Consolas, "Courier New", monospace;
font-size: 0.82rem;
line-height: 1.45;
background: var(--tk-gray-light);
}
.hidden-source { display: none; }
table { width: 100%; border-collapse: collapse; }
th, td { border-bottom: 1px solid #e6ebf4; text-align: left; padding: 0.55rem; vertical-align: top; }
.checkbox { display: flex; align-items: center; gap: 0.45rem; font-weight: 500; }
.display-options { border: 1px solid #dde3ef; border-radius: 6px; padding: 0.75rem; display: grid; gap: 0.45rem; }
.display-options legend { font-weight: 600; padding: 0 0.25rem; }
.error { color: #b42318; background: #fef3f2; border: 1px solid #fecdca; padding: 0.6rem; border-radius: 4px; margin: 0 0 0.75rem; }
.hint { color: #64748b; font-size: 0.92rem; margin: 0 0 0.6rem; }
/* ── Collapsible ── */
details.card-details {
background: var(--tk-white);
border: 1px solid var(--tk-border);
border-radius: var(--radius-lg);
margin-bottom: 1rem;
box-shadow: var(--shadow-sm);
}
details.card-details > summary {
padding: 1rem 1.35rem;
cursor: pointer;
font-weight: 600;
color: var(--tk-gray-dark);
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
details.card-details > summary::-webkit-details-marker { display: none; }
details.card-details > summary::after {
content: "▸";
color: var(--tk-gray);
transition: transform 0.2s;
}
details.card-details[open] > summary::after {
transform: rotate(90deg);
}
details.card-details > summary:hover { background: var(--tk-gray-light); }
details.card-details .details-body {
padding: 0 1.35rem 1.25rem;
border-top: 1px solid var(--tk-border);
}
/* ── Alerts ── */
.error, .alert-error {
color: var(--tk-error);
background: var(--tk-error-bg);
border: 1px solid #FECDCA;
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin: 0 0 0.85rem;
font-size: 0.9rem;
}
.hint {
color: var(--tk-gray);
font-size: 0.88rem;
margin: 0 0 0.75rem;
line-height: 1.5;
}
/* ── Login ── */
body.login-page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
body.login-page .topbar { flex-shrink: 0; }
body.login-page .container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.login-card {
width: 100%;
max-width: 400px;
border-top: 4px solid var(--tk-orange);
}
.login-card h2 {
margin: 0 0 0.35rem;
color: var(--tk-gray-dark);
}
.login-subtitle {
margin: 0 0 1.25rem;
color: var(--tk-gray);
font-size: 0.9rem;
}
/* ── Copy dialog ── */
.copy-dialog-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(26, 26, 26, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.copy-dialog {
width: min(900px, 100%);
max-height: 90vh;
background: var(--tk-white);
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.2);
border-top: 4px solid var(--tk-orange);
display: grid;
gap: 0.85rem;
}
.copy-dialog h4 { margin: 0; color: var(--tk-gray-dark); }
.copy-dialog-hint { margin: 0; color: var(--tk-gray); }
.copy-dialog-textarea {
width: 100%;
min-height: 320px;
font-family: Consolas, "Courier New", monospace;
font-size: 0.85rem;
line-height: 1.4;
border: 1px solid var(--tk-border);
border-radius: 6px;
padding: 0.75rem;
resize: vertical;
}
.copy-dialog-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* ── Footer ── */
.site-footer {
text-align: center;
padding: 1rem;
font-size: 0.8rem;
color: var(--tk-gray);
border-top: 1px solid var(--tk-border);
background: var(--tk-white);
}

View File

@@ -8,8 +8,7 @@ function setCopyStatus(message, isError) {
function getNewsletterHtml() {
const htmlSource = document.getElementById("newsletter-html-source");
const htmlDisplay = document.getElementById("newsletter-html-display");
const value = htmlSource?.value.trim() || htmlDisplay?.value.trim() || "";
return value;
return htmlSource?.value.trim() || htmlDisplay?.value.trim() || "";
}
function getNewsletterOutlookHtml() {
@@ -24,6 +23,10 @@ function getNewsletterPlain() {
return plainSource ? plainSource.value : "";
}
function byteLength(text) {
return new TextEncoder().encode(text).length;
}
function wrapCfHtml(html) {
const fragmentStart = "<!--StartFragment-->";
const fragmentEnd = "<!--EndFragment-->";
@@ -32,15 +35,20 @@ function wrapCfHtml(html) {
const documentHtml = `<html><body>${fragmentStart}${inner}${fragmentEnd}</body></html>`;
const pad = (value) => String(value).padStart(10, "0");
const headerPlaceholder = "StartHTML:0000000000\r\nEndHTML:0000000000\r\nStartFragment:0000000000\r\nEndFragment:0000000000\r\n";
const prefix = `Version:0.9\r\n${headerPlaceholder}`;
const startHtml = prefix.length;
const endHtml = startHtml + documentHtml.length;
const startFragment = startHtml + documentHtml.indexOf(fragmentStart) + fragmentStart.length;
const endFragment = startHtml + documentHtml.indexOf(fragmentEnd);
const headerTemplate =
"Version:0.9\r\n" +
"StartHTML:0000000000\r\n" +
"EndHTML:0000000000\r\n" +
"StartFragment:0000000000\r\n" +
"EndFragment:0000000000\r\n";
const startHtml = byteLength(headerTemplate);
const endHtml = startHtml + byteLength(documentHtml);
const startFragment = startHtml + byteLength(documentHtml.slice(0, documentHtml.indexOf(fragmentStart) + fragmentStart.length));
const endFragment = startHtml + byteLength(documentHtml.slice(0, documentHtml.indexOf(fragmentEnd)));
return (
`Version:0.9\r\n` +
"Version:0.9\r\n" +
`StartHTML:${pad(startHtml)}\r\n` +
`EndHTML:${pad(endHtml)}\r\n` +
`StartFragment:${pad(startFragment)}\r\n` +
@@ -49,26 +57,33 @@ function wrapCfHtml(html) {
);
}
function copyFromTextarea(textarea) {
function selectTextarea(textarea) {
if (!textarea || !textarea.value) return false;
const previousActive = document.activeElement;
textarea.removeAttribute("readonly");
textarea.scrollIntoView({ behavior: "smooth", block: "center" });
textarea.focus({ preventScroll: true });
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
return true;
}
function copyFromTextarea(textarea) {
if (!textarea || !textarea.value) return false;
const wasReadonly = textarea.hasAttribute("readonly");
if (wasReadonly) textarea.removeAttribute("readonly");
selectTextarea(textarea);
let ok = false;
try {
ok = document.execCommand("copy");
} catch (err) {
console.warn("execCommand copy from textarea failed.", err);
ok = false;
} finally {
textarea.setAttribute("readonly", "");
if (previousActive && typeof previousActive.focus === "function") {
previousActive.focus({ preventScroll: true });
}
if (wasReadonly) textarea.setAttribute("readonly", "");
if (!ok) {
selectTextarea(textarea);
}
return ok;
}
@@ -92,47 +107,151 @@ function copyTextWithFallback(text) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("aria-hidden", "true");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.width = "2px";
textarea.style.height = "2px";
textarea.style.padding = "0";
textarea.style.border = "none";
textarea.style.outline = "none";
textarea.style.boxShadow = "none";
textarea.style.background = "transparent";
textarea.style.cssText = "position:fixed;top:0;left:0;width:2px;height:2px;opacity:0;border:none;padding:0;";
document.body.appendChild(textarea);
let ok = false;
try {
textarea.focus({ preventScroll: true });
textarea.select();
textarea.setSelectionRange(0, text.length);
selectTextarea(textarea);
ok = document.execCommand("copy");
} catch (err) {
console.warn("execCommand copy from temporary textarea failed.", err);
ok = false;
} finally {
document.body.removeChild(textarea);
}
return ok;
}
let copyDialogKeyHandler = null;
function closeCopyDialog() {
const overlay = document.getElementById("copy-dialog-overlay");
if (overlay) overlay.remove();
if (copyDialogKeyHandler) {
document.removeEventListener("keydown", copyDialogKeyHandler);
copyDialogKeyHandler = null;
}
}
function showCopyDialog(text, title) {
closeCopyDialog();
const overlay = document.createElement("div");
overlay.id = "copy-dialog-overlay";
overlay.className = "copy-dialog-overlay";
overlay.innerHTML = `
<div class="copy-dialog" role="dialog" aria-modal="true" aria-labelledby="copy-dialog-title">
<h4 id="copy-dialog-title">${title}</h4>
<p class="copy-dialog-hint">Der Text ist markiert. Jetzt <strong>Strg+C</strong> drücken (Mac: <strong>Cmd+C</strong>).</p>
<textarea class="copy-dialog-textarea" spellcheck="false"></textarea>
<div class="copy-dialog-actions">
<button type="button" class="btn-secondary" id="copy-dialog-select-btn">Alles markieren</button>
<button type="button" class="btn-secondary" id="copy-dialog-close-btn">Schließen</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const textarea = overlay.querySelector(".copy-dialog-textarea");
textarea.value = text;
selectTextarea(textarea);
let copied = false;
try {
copied = document.execCommand("copy");
} catch (err) {
console.warn("execCommand copy in dialog failed.", err);
}
if (copied) {
setCopyStatus("HTML-Quelltext kopiert.");
} else {
setCopyStatus("Bitte im Fenster Strg+C drücken.", true);
}
overlay.querySelector("#copy-dialog-close-btn").addEventListener("click", closeCopyDialog);
overlay.querySelector("#copy-dialog-select-btn").addEventListener("click", () => selectTextarea(textarea));
overlay.addEventListener("click", (event) => {
if (event.target === overlay) closeCopyDialog();
});
copyDialogKeyHandler = (event) => {
if (event.key === "Escape") closeCopyDialog();
};
document.addEventListener("keydown", copyDialogKeyHandler);
}
function copyFromPreviewFrame() {
const frame = document.getElementById("newsletter-preview-frame");
const doc = frame?.contentDocument;
const win = frame?.contentWindow;
if (!doc?.body || !win || !doc.body.innerHTML.trim()) {
return false;
}
const range = doc.createRange();
range.selectNodeContents(doc.body);
const selection = win.getSelection();
selection.removeAllRanges();
selection.addRange(range);
let ok = false;
try {
ok = doc.execCommand("copy");
} catch (err) {
console.warn("Copy from preview frame failed.", err);
} finally {
selection.removeAllRanges();
}
return ok;
}
function loadPreviewFrame() {
const frame = document.getElementById("newsletter-preview-frame");
const html = getNewsletterOutlookHtml();
if (frame && html) {
frame.srcdoc = html;
}
}
function copyViaIframe(html) {
const iframe = document.createElement("iframe");
iframe.setAttribute("aria-hidden", "true");
iframe.style.cssText = "position:fixed;width:700px;height:500px;left:-9999px;top:0;border:none;";
document.body.appendChild(iframe);
const doc = iframe.contentWindow.document;
doc.open();
doc.write(html);
doc.close();
const range = doc.createRange();
range.selectNodeContents(doc.body);
const selection = iframe.contentWindow.getSelection();
selection.removeAllRanges();
selection.addRange(range);
let ok = false;
try {
ok = doc.execCommand("copy");
} catch (err) {
console.warn("execCommand copy via iframe failed.", err);
} finally {
selection.removeAllRanges();
document.body.removeChild(iframe);
}
return ok;
}
function copyHtmlWithFallback(html) {
if (copyViaIframe(html)) return true;
const container = document.createElement("div");
container.innerHTML = html;
container.contentEditable = "true";
container.setAttribute("aria-hidden", "true");
container.style.position = "fixed";
container.style.left = "0";
container.style.top = "0";
container.style.width = "600px";
container.style.height = "1px";
container.style.overflow = "hidden";
container.style.opacity = "0";
container.style.pointerEvents = "none";
container.style.background = "#FFFFFF";
container.style.cssText =
"position:fixed;left:0;top:0;width:700px;max-height:1px;overflow:hidden;opacity:0;pointer-events:none;background:#FFFFFF;";
document.body.appendChild(container);
const range = document.createRange();
@@ -146,36 +265,13 @@ function copyHtmlWithFallback(html) {
ok = document.execCommand("copy");
} catch (err) {
console.warn("execCommand HTML copy failed.", err);
ok = false;
} finally {
selection.removeAllRanges();
document.body.removeChild(container);
}
if (!ok) {
ok = copyTextWithFallback(wrapCfHtml(html));
}
return ok;
}
async function copyText(text) {
if (!text) return false;
if (copyTextWithFallback(text)) {
return true;
}
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.warn("Clipboard API text copy failed.", err);
}
}
return false;
}
async function copyForOutlook() {
const html = getNewsletterOutlookHtml();
const plain = getNewsletterPlain();
@@ -184,8 +280,13 @@ async function copyForOutlook() {
return;
}
if (copyHtmlWithFallback(html)) {
setCopyStatus("Newsletter für Outlook kopiert. Mit Strg+V einfügen (ggf. „Format beibehalten“).");
if (copyFromPreviewFrame()) {
setCopyStatus("Vorschau kopiert in Outlook mit Strg+V einfügen (Rechtsklick → „Format beibehalten“).");
return;
}
if (copyViaIframe(html)) {
setCopyStatus("Newsletter kopiert in Outlook mit Strg+V einfügen (Rechtsklick → „Format beibehalten“).");
return;
}
@@ -198,55 +299,24 @@ async function copyForOutlook() {
"text/plain": new Blob([plain], { type: "text/plain" }),
}),
]);
setCopyStatus("Newsletter für Outlook kopiert. Mit Strg+V einfügen (ggf. „Format beibehalten“).");
setCopyStatus("Newsletter kopiert in Outlook mit Strg+V einfügen.");
return;
} catch (err) {
console.warn("Clipboard API HTML copy failed.", err);
}
}
setCopyStatus("Kopieren fehlgeschlagen. Bitte „HTML herunterladen“ verwenden.", true);
setCopyStatus("Kopieren fehlgeschlagen. Bitte „Im Browser öffnen“ nutzen.", true);
}
async function copyHtmlSource() {
function copyHtmlSource() {
const html = getNewsletterHtml();
if (!html) {
setCopyStatus("Bitte zuerst einen Newsletter generieren.", true);
return;
}
const displayArea = document.getElementById("newsletter-html-display");
const sourceArea = document.getElementById("newsletter-html-source");
const textarea = displayArea?.value ? displayArea : sourceArea;
if (textarea && copyFromTextarea(textarea)) {
setCopyStatus("HTML-Quelltext kopiert.");
return;
}
if (copyTextWithFallback(html)) {
setCopyStatus("HTML-Quelltext kopiert.");
return;
}
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(html);
setCopyStatus("HTML-Quelltext kopiert.");
return;
} catch (err) {
console.warn("Clipboard API HTML source copy failed.", err);
}
}
if (textarea) {
textarea.focus({ preventScroll: true });
textarea.select();
}
setCopyStatus(
"Automatisches Kopieren nicht möglich. HTML-Feld unten ist markiert bitte Strg+C drücken.",
true
);
showCopyDialog(html, "HTML-Quelltext kopieren");
}
function downloadHtml() {
@@ -256,9 +326,23 @@ function downloadHtml() {
return;
}
const stamp = new Date().toISOString().slice(0, 10);
const filename = `wiki-newsletter-${stamp}.html`;
const blob = new Blob([html], { type: "text/html;charset=utf-8" });
downloadFile(html, `wiki-newsletter-${new Date().toISOString().slice(0, 10)}.html`, "text/html;charset=utf-8");
setCopyStatus("Outlook-HTML heruntergeladen.");
}
function downloadHtmlSource() {
const html = getNewsletterHtml();
if (!html) {
setCopyStatus("Bitte zuerst einen Newsletter generieren.", true);
return;
}
downloadFile(html, `wiki-newsletter-source-${new Date().toISOString().slice(0, 10)}.html`, "text/html;charset=utf-8");
setCopyStatus("HTML-Quelltext heruntergeladen.");
}
function downloadFile(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
@@ -267,19 +351,39 @@ function downloadHtml() {
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
setCopyStatus(`HTML gespeichert als ${filename}. Datei in Outlook öffnen oder Inhalt kopieren.`);
}
function openOutlookInBrowser() {
const html = getNewsletterOutlookHtml();
if (!html) {
setCopyStatus("Bitte zuerst einen Newsletter generieren.", true);
return;
}
const popup = window.open("", "_blank");
if (!popup) {
setCopyStatus("Pop-up blockiert. Bitte Pop-ups erlauben oder „HTML herunterladen“ nutzen.", true);
return;
}
popup.document.open();
popup.document.write(html);
popup.document.close();
popup.document.title = "Wiki-Newsletter zum Kopieren";
setCopyStatus("Im neuen Tab: Alles markieren (Strg+A) → kopieren (Strg+C) → in Outlook einfügen.");
}
document.addEventListener("DOMContentLoaded", () => {
const outlookSource = document.getElementById("newsletter-outlook-source");
const htmlSource = document.getElementById("newsletter-html-source");
const frame = document.getElementById("newsletter-preview-frame");
const previewHtml = outlookSource?.value.trim() || htmlSource?.value.trim() || "";
if (frame && previewHtml) {
frame.srcdoc = previewHtml;
}
loadPreviewFrame();
const htmlDisplay = document.getElementById("newsletter-html-display");
htmlDisplay?.addEventListener("focus", () => selectTextarea(htmlDisplay));
htmlDisplay?.addEventListener("click", () => selectTextarea(htmlDisplay));
document.getElementById("copy-outlook-btn")?.addEventListener("click", copyForOutlook);
document.getElementById("open-outlook-btn")?.addEventListener("click", openOutlookInBrowser);
document.getElementById("copy-html-source-btn")?.addEventListener("click", copyHtmlSource);
document.getElementById("copy-html-source-btn-2")?.addEventListener("click", copyHtmlSource);
document.getElementById("download-html-btn")?.addEventListener("click", downloadHtml);
document.getElementById("download-html-source-btn")?.addEventListener("click", downloadHtmlSource);
});

View File

@@ -3,22 +3,43 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title if title else "TK Wiki Newsletter Admin" }}</title>
<link rel="stylesheet" href="/static/css/main.css">
<title>{% block title %}{{ title if title else "Wiki Newsletter Admin" }}{% endblock %} · Thomas-Krenn.AG</title>
<link rel="stylesheet" href="/static/css/main.css?v=4">
</head>
<body>
<body class="{% block body_class %}{% endblock %}">
<header class="topbar">
<h1>TK Wiki Newsletter Admin</h1>
<div class="topbar-brand">
<div class="topbar-logo" aria-hidden="true">TK</div>
<div>
<h1>Wiki Newsletter Admin</h1>
<p class="topbar-sub">Thomas-Krenn.AG · Intern</p>
</div>
</div>
{% if user %}
<div class="topbar-actions">
<nav class="topbar-nav" aria-label="Hauptnavigation">
<a href="/dashboard" class="{% if active_nav == 'dashboard' %}active{% endif %}">Dashboard</a>
{% if user.role == "admin" %}
<a href="/admin/users" class="{% if active_nav == 'users' %}active{% endif %}">Benutzer</a>
{% endif %}
</nav>
<div class="user-chip">
<span>{{ user.full_name }}</span>
<span class="badge badge-{{ user.role }}">{{ user.role }}</span>
</div>
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Logout</button>
<button type="submit" class="btn-logout">Abmelden</button>
</form>
</div>
{% endif %}
</header>
<main class="container">
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
Thomas-Krenn.AG · Interner Wiki-Newsletter · Nur für den internen Gebrauch
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,21 +1,64 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<section class="card">
<h2>Newsletter erstellen</h2>
<div class="page-header">
<h2>Wiki Newsletter</h2>
<p class="page-lead">Wiki-Änderungen filtern, Newsletter generieren und für Outlook aufbereiten.</p>
</div>
<div class="stats-row">
<div class="stat-card accent-new">
<div class="stat-value">{{ articles_new|length }}</div>
<div class="stat-label">Neue Artikel</div>
</div>
<div class="stat-card accent-edit">
<div class="stat-value">{{ articles_edited|length }}</div>
<div class="stat-label">Bearbeitete Artikel</div>
</div>
<div class="stat-card accent-total">
<div class="stat-value">{{ articles_new|length + articles_edited|length }}</div>
<div class="stat-label">Gesamt</div>
</div>
</div>
<div class="layout-2col">
<div class="main-col">
<section class="card card-accent-orange">
<div class="card-header">
<div>
<h3>Newsletter erstellen</h3>
<p class="card-desc">Zeitraum und Filter wählen, dann generieren.</p>
</div>
</div>
<form method="post" action="/dashboard" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Zeitraum (Tage)
<div class="form-row">
<label>Zeitraum
<select name="period">
<option value="days" {% if filters.period != 'last_month' %}selected{% endif %}>Letzte X Tage</option>
<option value="last_month" {% if filters.period == 'last_month' %}selected{% endif %}>Letzter Monat (voller Kalendermonat)</option>
</select>
</label>
<label>Tage (nur bei „Letzte X Tage“)
<input type="number" name="days" min="1" max="90" value="{{ filters.days }}">
</label>
</div>
<div class="form-row">
<label>Kategorie (optional)
<input type="text" name="category" value="{{ filters.category }}">
<input type="text" name="category" value="{{ filters.category }}" placeholder="z. B. Linux">
</label>
<label class="checkbox">
<input type="checkbox" name="only_edited" value="true" {% if filters.only_edited %}checked{% endif %}>
Nur bearbeitete Artikel (keine neu erstellten)
</div>
<label>Artikel-Auswahl
<select name="article_type">
<option value="all" {% if filters.article_type == 'all' %}selected{% endif %}>Alle (neue &amp; bearbeitete)</option>
<option value="new" {% if filters.article_type == 'new' %}selected{% endif %}>Nur neue Artikel</option>
<option value="edited" {% if filters.article_type == 'edited' %}selected{% endif %}>Nur bearbeitete Artikel (keine neu erstellten)</option>
</select>
</label>
<fieldset class="display-options">
<legend>Anzeige im Output</legend>
<legend>Anzeige im Newsletter</legend>
<label class="checkbox">
<input type="checkbox" name="show_date" value="true" {% if filters.display.show_date %}checked{% endif %}>
Datum anzeigen
@@ -29,102 +72,86 @@
Kategorie anzeigen
</label>
</fieldset>
{% if user.role in ["admin", "editor"] %}
<button type="submit">Newsletter generieren</button>
{% endif %}
</form>
{% if user.role == "admin" %}
<p><a href="/admin/users">Zur Benutzerverwaltung</a></p>
{% endif %}
<label>Top-Highlights (optional)
<textarea name="highlights" rows="3" placeholder="Ein Artikeltitel pro Zeile muss exakt einem gefundenen Artikel entsprechen (max. 3)">{{ filters.highlights }}</textarea>
</label>
<p class="hint">Hervorgehobene Artikel erscheinen als „Top Highlights“-Box oben im Newsletter.</p>
<label>Redaktionsnotiz (optional)
<textarea name="editor_tip" rows="3" placeholder="Optionaler Hinweis der Redaktion für diese Ausgabe…">{{ filters.editor_tip }}</textarea>
</label>
<p class="hint">Wird nur angezeigt, wenn Text hinterlegt ist erscheint als eigene Box im Newsletter.</p>
{% if wiki_error %}
<p class="error">{{ wiki_error }}</p>
{% endif %}
</section>
{% if user.role in ["admin", "editor"] %}
<section class="card">
<h3>Optionaler SMTP-Versand</h3>
<form method="post" action="/newsletter/send-now" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="days" value="{{ filters.days }}">
<input type="hidden" name="category" value="{{ filters.category }}">
<input type="hidden" name="only_edited" value="{{ 'true' if filters.only_edited else 'false' }}">
<input type="hidden" name="show_date" value="{{ 'true' if filters.display.show_date else 'false' }}">
<input type="hidden" name="show_user" value="{{ 'true' if filters.display.show_user else 'false' }}">
<input type="hidden" name="show_category" value="{{ 'true' if filters.display.show_category else 'false' }}">
<button type="submit">Newsletter jetzt senden</button>
{% if user.role in ["admin", "editor"] %}
<button type="submit" class="btn-primary">Newsletter generieren</button>
{% else %}
<p class="hint">Sie haben Leserechte. Bitte einen Editor oder Admin bitten, den Newsletter zu generieren.</p>
{% endif %}
</form>
</section>
{% endif %}
</section>
{% if user.role == "admin" %}
<section class="card">
<h3>SMTP-Konfiguration (optional)</h3>
<form method="post" action="/admin/smtp" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {% if smtp.enabled == "true" %}checked{% endif %}>
SMTP Versand aktivieren
</label>
<label>SMTP Host
<input type="text" name="host" value="{{ smtp.host }}">
</label>
<label>SMTP Port
<input type="number" name="port" value="{{ smtp.port }}">
</label>
<label>Benutzername
<input type="text" name="username" value="{{ smtp.username }}">
</label>
<label>Passwort
<input type="password" name="password" value="{{ smtp.password }}">
</label>
<label>Absender E-Mail
<input type="email" name="from_email" value="{{ smtp.from_email }}">
</label>
<label class="checkbox">
<input type="checkbox" name="use_tls" value="true" {% if smtp.use_tls == "true" %}checked{% endif %}>
STARTTLS verwenden
</label>
<label>Verteilerliste (Komma-separiert)
<textarea rows="3" name="recipients">{{ smtp.recipients }}</textarea>
</label>
<label>Geplanter täglicher Versand (HH:MM)
<input type="time" name="schedule_time" value="{{ smtp.schedule_time }}">
</label>
<button type="submit">SMTP Einstellungen speichern</button>
</form>
</section>
{% endif %}
<section class="card">
<h3>Newsletter-Vorschau (mit Hyperlinks)</h3>
<p class="hint">Für Outlook „Für Outlook kopieren“ nutzen und mit <strong>Strg+V</strong> einfügen (Rechtsklick → „Format beibehalten“). Falls Kopieren nicht klappt (z. B. per HTTP/IP), „HTML herunterladen“ verwenden und die Datei in Outlook öffnen.</p>
<section class="card card-accent-orange">
<div class="card-header">
<div>
<h3>Vorschau &amp; Export</h3>
<p class="card-desc">Formatierten Newsletter ansehen und für Outlook kopieren.</p>
</div>
</div>
<p class="hint">Die Vorschau unten zeigt <strong>exakt</strong>, wie der Newsletter aussieht. „Vorschau kopieren“ übernimmt dieses Layout 1:1 in die Zwischenablage.</p>
<div class="copy-actions">
<button type="button" id="copy-outlook-btn" class="btn-secondary">Für Outlook kopieren</button>
<button type="button" id="copy-html-source-btn" class="btn-secondary">HTML-Quelltext kopieren</button>
<button type="button" id="copy-outlook-btn" class="btn-primary">Vorschau kopieren</button>
<button type="button" id="open-outlook-btn" class="btn-secondary">Im Browser öffnen</button>
<button type="button" id="download-html-btn" class="btn-secondary">HTML herunterladen</button>
<button type="button" id="copy-html-source-btn" class="btn-outline">HTML-Quelltext</button>
<span id="copy-status" class="copy-status" aria-live="polite"></span>
</div>
<div class="preview-banner">Live-Vorschau so wird der Newsletter dargestellt</div>
<iframe id="newsletter-preview-frame" class="newsletter-preview-frame" title="Newsletter Vorschau"></iframe>
<textarea id="newsletter-html-source" class="hidden-source" hidden readonly>{{ raw_html }}</textarea>
<textarea id="newsletter-outlook-source" class="hidden-source" hidden readonly>{{ outlook_html }}</textarea>
<textarea id="newsletter-plain-source" class="hidden-source" hidden readonly>{{ plain_text }}</textarea>
</section>
<section class="card">
<h3>Output: Nur-Text (Fallback)</h3>
<p class="hint">Enthält URLs als Text. Für klickbare Links bitte die Vorschau bzw. „Für Outlook kopieren“ verwenden.</p>
<textarea rows="16" readonly>{{ plain_text }}</textarea>
</section>
{% if send_result %}
<div class="send-banner send-banner-{{ send_result.status }}">{{ send_result.message }}</div>
{% endif %}
<section class="card">
<h3>Output: HTML-Quelltext</h3>
<p class="hint">Bei Problemen mit „Kopieren“: Textfeld markieren (Strg+A) und manuell kopieren (Strg+C).</p>
<textarea id="newsletter-html-display" rows="12" readonly>{{ raw_html }}</textarea>
</section>
{% if raw_html and (articles_new or articles_edited) %}
<div class="send-section">
<h4 style="margin:0 0 0.35rem;">Per E-Mail versenden</h4>
<p class="hint" style="margin:0 0 0.75rem;">Bitte oben die Vorschau prüfen. Beim Versenden wird genau dieser Newsletter (aktuelle Filter) an die Verteilerliste geschickt.</p>
<p class="hint" style="margin:0 0 0.75rem;">
<strong>Betreff:</strong> {{ subject }}<br>
<strong>Empfänger:</strong> {{ smtp.recipients if smtp.recipients else "— keine konfiguriert —" }}
{% if smtp.enabled != "true" %}<br><strong style="color:var(--tk-orange-dark);">SMTP ist deaktiviert</strong> bitte zuerst in „SMTP-Konfiguration“ aktivieren.{% endif %}
</p>
{% if user.role in ["admin", "editor"] %}
<form method="post" action="/newsletter/send-now" onsubmit="return confirm('Diesen Newsletter jetzt per E-Mail an die Verteilerliste senden?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="period" value="{{ filters.period }}">
<input type="hidden" name="days" value="{{ filters.days }}">
<input type="hidden" name="category" value="{{ filters.category }}">
<input type="hidden" name="article_type" value="{{ filters.article_type }}">
<input type="hidden" name="show_date" value="{{ 'true' if filters.display.show_date else 'false' }}">
<input type="hidden" name="show_user" value="{{ 'true' if filters.display.show_user else 'false' }}">
<input type="hidden" name="show_category" value="{{ 'true' if filters.display.show_category else 'false' }}">
<input type="hidden" name="highlights" value="{{ filters.highlights }}">
<input type="hidden" name="editor_tip" value="{{ filters.editor_tip }}">
<button type="submit" class="btn-primary">Newsletter jetzt senden</button>
</form>
{% else %}
<p class="hint">Sie haben Leserechte Versand nur für Editor/Admin.</p>
{% endif %}
</div>
{% endif %}
</section>
<section class="card">
<h3>Neue Artikel ({{ articles_new|length }})</h3>
{% if filters.article_type != 'edited' %}
<section class="card card-accent-gray">
<div class="card-header">
<h3>Neue Artikel <span class="count-badge orange">{{ articles_new|length }}</span></h3>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
@@ -143,14 +170,20 @@
{% if filters.display.show_category %}<td>{{ item.categories|join(", ") }}</td>{% endif %}
</tr>
{% else %}
<tr><td colspan="4">Keine neuen Artikel im gewählten Zeitraum.</td></tr>
<tr class="empty-row"><td colspan="4">Keine neuen Artikel im gewählten Zeitraum.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
</section>
{% endif %}
<section class="card">
<h3>Bearbeitete Artikel ({{ articles_edited|length }})</h3>
{% if filters.article_type != 'new' %}
<section class="card card-accent-gray">
<div class="card-header">
<h3>Bearbeitete Artikel <span class="count-badge">{{ articles_edited|length }}</span></h3>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
@@ -169,31 +202,138 @@
{% if filters.display.show_category %}<td>{{ item.categories|join(", ") }}</td>{% endif %}
</tr>
{% else %}
<tr><td colspan="4">Keine bearbeiteten Artikel im gewählten Zeitraum.</td></tr>
<tr class="empty-row"><td colspan="4">Keine bearbeiteten Artikel im gewählten Zeitraum.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
</section>
{% endif %}
<section class="card">
<h3>Versandprotokoll (letzte 20)</h3>
<details class="card-details">
<summary>Weitere Ausgabeformate</summary>
<div class="details-body">
<h4 style="margin:1rem 0 0.5rem;font-size:0.95rem;">Nur-Text (Fallback)</h4>
<p class="hint">URLs als Klartext für klickbare Links die Vorschau nutzen.</p>
<textarea class="readonly-output" rows="10" readonly>{{ plain_text }}</textarea>
<h4 style="margin:1.25rem 0 0.5rem;font-size:0.95rem;">HTML-Quelltext</h4>
<p class="hint">Öffnet ein Fenster mit markiertem Text dort <strong>Strg+C</strong> drücken.</p>
<div class="textarea-actions">
<button type="button" id="copy-html-source-btn-2" class="btn-secondary">HTML-Quelltext kopieren</button>
<button type="button" id="download-html-source-btn" class="btn-outline">Herunterladen</button>
</div>
<textarea id="newsletter-html-display" class="readonly-output" rows="8" readonly>{{ raw_html }}</textarea>
</div>
</details>
{% if user.role == "admin" %}
<details class="card-details">
<summary>SMTP-Konfiguration</summary>
<div class="details-body">
<form method="post" action="/admin/smtp" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {% if smtp.enabled == "true" %}checked{% endif %}>
SMTP-Versand aktivieren
</label>
<div class="form-row">
<label>SMTP-Host
<input type="text" name="host" value="{{ smtp.host }}" placeholder="mail.example.com">
</label>
<label>SMTP-Port
<input type="number" name="port" value="{{ smtp.port }}">
</label>
</div>
<div class="form-row">
<label>Benutzername
<input type="text" name="username" value="{{ smtp.username }}">
</label>
<label>Passwort
<input type="password" name="password" value="{{ smtp.password }}">
</label>
</div>
<label>Absender E-Mail
<input type="email" name="from_email" value="{{ smtp.from_email }}">
</label>
<label class="checkbox">
<input type="checkbox" name="use_tls" value="true" {% if smtp.use_tls == "true" %}checked{% endif %}>
STARTTLS verwenden
</label>
<label>Verteilerliste (kommagetrennt)
<textarea rows="3" name="recipients" placeholder="kollege@firma.de, team@firma.de">{{ smtp.recipients }}</textarea>
</label>
<label>Geplanter täglicher Versand (HH:MM)
<input type="time" name="schedule_time" value="{{ smtp.schedule_time }}">
</label>
<button type="submit">Einstellungen speichern</button>
</form>
</div>
</details>
{% endif %}
<section class="card">
<div class="card-header">
<h3>Versandprotokoll</h3>
<span class="hint" style="margin:0;">Letzte 20 Einträge</span>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Zeit</th><th>Status</th><th>Betreff</th><th>Empfänger</th><th>Details</th></tr></thead>
<thead>
<tr><th>Zeit</th><th>Status</th><th>Betreff</th><th>Empfänger</th><th>Details</th></tr>
</thead>
<tbody>
{% for log in send_logs %}
<tr>
<td>{{ log.created_at }}</td>
<td>{{ log.status }}</td>
<td><span class="badge badge-{{ log.status }}">{{ log.status }}</span></td>
<td>{{ log.subject }}</td>
<td>{{ log.recipients }}</td>
<td>{{ log.detail }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="5">Noch keine Versände protokolliert.</td></tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
</section>
</div>
<aside class="sidebar-col">
<div class="sidebar-card">
<h4>Workflow</h4>
<ol style="margin:0;padding-left:1.1rem;color:var(--tk-gray);">
<li>Filter setzen</li>
<li>Newsletter generieren</li>
<li>Vorschau prüfen</li>
<li>Für Outlook exportieren</li>
</ol>
</div>
<div class="sidebar-card">
<h4>Outlook-Tipp</h4>
<ul>
<li><strong>Vorschau kopieren</strong> übernimmt die Live-Vorschau</li>
<li>In Outlook: <strong>Strg+V</strong></li>
<li>Rechtsklick → <strong>Format beibehalten</strong></li>
<li>Bei Dunkelmodus: Outlook-Design auf „Weiß“ stellen</li>
</ul>
</div>
{% if user.role == "admin" %}
<div class="sidebar-card">
<h4>Administration</h4>
<ul>
<li><a href="/admin/users">Benutzerverwaltung</a></li>
<li>SMTP unter „SMTP-Konfiguration“</li>
</ul>
</div>
{% endif %}
</aside>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/newsletter.js"></script>
<script src="/static/js/newsletter.js?v=8"></script>
{% endblock %}

View File

@@ -1,19 +1,22 @@
{% extends "base.html" %}
{% block title %}Anmelden{% endblock %}
{% block body_class %}login-page{% endblock %}
{% block content %}
<section class="card narrow">
<h2>Login</h2>
<section class="card login-card">
<h2>Anmelden</h2>
<p class="login-subtitle">Thomas-Krenn Wiki Newsletter Admin</p>
{% if error_message %}
<p class="error">{{ error_message }}</p>
{% endif %}
<form method="post" action="/login" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>E-Mail
<input type="email" name="email" required>
<input type="email" name="email" required autocomplete="username">
</label>
<label>Passwort
<input type="password" name="password" required>
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit">Anmelden</button>
<button type="submit" class="btn-primary btn-block">Anmelden</button>
</form>
</section>
{% endblock %}

View File

@@ -1,43 +1,84 @@
{% extends "base.html" %}
{% block title %}Benutzerverwaltung{% endblock %}
{% block content %}
<section class="card">
<div class="page-header">
<h2>Benutzerverwaltung</h2>
<p class="page-lead"><a href="/dashboard">← Zurück zum Dashboard</a></p>
</div>
<div class="layout-2col">
<div class="main-col">
<section class="card card-accent-orange">
<div class="card-header">
<h3>Neuen Benutzer anlegen</h3>
</div>
{% if error_message %}
<p class="error">{{ error_message }}</p>
{% endif %}
{% if success_message %}
<p class="hint" style="color:var(--tk-success);font-weight:600;">{{ success_message }}</p>
{% endif %}
<form method="post" action="/admin/users" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-row">
<label>E-Mail
<input type="email" name="email" required>
<input type="email" name="email" required placeholder="name@firma.de">
</label>
<label>Vollständiger Name
<input type="text" name="full_name" required>
</label>
</div>
<div class="form-row">
<label>Passwort
<input type="password" name="password" required minlength="10">
<input type="password" name="password" required minlength="10" placeholder="Mindestens 10 Zeichen">
</label>
<label>Rolle
<select name="role">
<option value="reader">Reader</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
<option value="reader">Leser (nur ansehen)</option>
<option value="editor">Editor (generieren &amp; senden)</option>
<option value="admin">Admin (voller Zugriff)</option>
</select>
</label>
<button type="submit">Benutzer erstellen</button>
</div>
<button type="submit" class="btn-primary">Benutzer erstellen</button>
</form>
</section>
</section>
<section class="card">
<h3>Bestehende Benutzer</h3>
<section class="card">
<div class="card-header">
<h3>Bestehende Benutzer <span class="count-badge">{{ users|length }}</span></h3>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>E-Mail</th><th>Name</th><th>Rolle</th><th>Aktiv</th></tr></thead>
<thead>
<tr><th>E-Mail</th><th>Name</th><th>Rolle</th><th>Status</th></tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.email }}</td>
<td>{{ u.full_name }}</td>
<td>{{ u.role }}</td>
<td>{{ "Ja" if u.is_active else "Nein" }}</td>
<td><span class="badge badge-{{ u.role }}">{{ u.role }}</span></td>
<td>{{ "Aktiv" if u.is_active else "Inaktiv" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
</section>
</div>
<aside class="sidebar-col">
<div class="sidebar-card">
<h4>Rollen</h4>
<ul>
<li><strong>Leser</strong> Newsletter ansehen</li>
<li><strong>Editor</strong> Generieren &amp; senden</li>
<li><strong>Admin</strong> Benutzer &amp; SMTP</li>
</ul>
</div>
</aside>
</div>
{% endblock %}

129
preview_send.html Normal file
View File

@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interner Wiki-Newsletter Thomas-Krenn.AG (30 Tage)</title>
<style type="text/css">
html, body { margin:0 !important; padding:0 !important; width:100% !important; background:#ECEEF1; }
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; }
a { text-decoration:none; }
.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: 700px) {
.email-container { width:100% !important; }
.mobile-padding { padding-left:16px !important; padding-right:16px !important; }
.mobile-headline { font-size:28px !important; line-height:34px !important; }
.mobile-card-title { font-size:18px !important; line-height:25px !important; }
}
</style>
<!--[if mso]>
<style type="text/css">
body, table, td, p, a, li, h1, h2, h3, span, strong { font-family: Arial, sans-serif !important; }
</style>
<![endif]-->
</head>
<body style="margin:0;padding:0;background:#ECEEF1;font-family:Arial, Helvetica, sans-serif;color:#1A1A1A;">
<div class="preheader">Es gibt 3 neue bzw. aktualisierte Wiki-Artikel.&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;</div>
<center role="article" aria-roledescription="email" lang="de" style="width:100%;background:#ECEEF1;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#ECEEF1" style="width:100%;background:#ECEEF1;">
<tr>
<td align="center" style="padding:22px 10px;">
<table role="presentation" width="700" cellpadding="0" cellspacing="0" border="0" class="email-container" style="width:100%;max-width:700px;background:#FFFFFF;border-top:4px solid #ED6B06;">
<tr>
<td align="center" style="padding:24px 32px 18px 32px;background:#4A4A4A;" class="mobile-padding">
<img src="https://www.thomas-krenn.com/res/pics/tk_logo_340px.png" width="190" 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 32px;" class="mobile-padding">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background:#4A4A4A;">
<tr>
<td style="padding:28px 26px;font-family:Arial, Helvetica, sans-serif;">
<div style="font-size:12px;line-height:16px;font-weight:bold;color:#ED6B06;letter-spacing:1px;text-transform:uppercase;">Thomas-Krenn Wiki Update</div>
<div class="mobile-headline" style="font-size:32px;line-height:38px;font-weight:bold;color:#FFFFFF;margin-top:10px;">Die neuesten Wiki-Artikel</div>
<div style="font-size:16px;line-height:25px;color:#DDDDDD;margin-top:10px;">07.06.2026 07.07.2026 &nbsp;|&nbsp; Es gibt 3 neue bzw. aktualisierte Wiki-Artikel.</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:22px 32px 6px 32px;" class="mobile-padding">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#FFF4ED;border-left:4px solid #ED6B06;">
<tr>
<td style="padding:18px 20px;font-family:Arial, Helvetica, sans-serif;font-size:15px;line-height:24px;color:#1A1A1A;">
<p style="margin:0 0 10px;font-family:Arial, Helvetica, sans-serif;">Servus,</p>
<p style="margin:0;font-family:Arial, Helvetica, sans-serif;">hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom 07.06.2026 bis 07.07.2026.</p>
</td>
</tr>
</table>
</td>
</tr>
<tr><td style="padding:8px 32px 4px 32px;" class="mobile-padding"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background:#FFF4ED;border:1px solid #F5C9A6;border-radius:12px;"><tr><td style="padding:16px 18px 8px 18px;font-family:Arial, Helvetica, sans-serif;font-size:11px;line-height:15px;color:#C95605;font-weight:bold;letter-spacing:.8px;text-transform:uppercase;">Top Highlights</td></tr><tr><td style="padding:0 18px 12px 18px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td style="padding:0 0 8px 0;font-family:Arial, Helvetica, sans-serif;font-size:15px;line-height:22px;color:#1A1A1A;"><span style="display:inline-block;min-width:22px;font-weight:bold;color:#ED6B06;">1.</span> <a href="https://www.thomas-krenn.com/de/wiki/RAID_Grundlagen_und_Konfiguration" target="_blank" style="color:#1A1A1A;text-decoration:none;">RAID Grundlagen und Konfiguration</a></td></tr></table></td></tr></table></td></tr>
<tr><td style="padding:26px 32px 8px 32px;font-family:Arial, Helvetica, sans-serif;" class="mobile-padding"><div style="font-family:Arial, Helvetica, sans-serif;font-size:18px;line-height:24px;font-weight:bold;color:#4A4A4A;border-bottom:2px solid #ED6B06;padding-bottom:8px;">Neue Artikel <span style="color:#ED6B06;">(2)</span></div></td></tr><tr><td style="padding:0 32px 16px 32px;" class="mobile-padding"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background:#FFFFFF;border:1px solid #E0E0E0;border-left:4px solid #ED6B06;border-radius:12px;"><tr><td style="padding:16px 22px 4px 22px;font-family:Arial, Helvetica, sans-serif;font-size:11px;line-height:16px;font-weight:bold;letter-spacing:.7px;text-transform:uppercase;color:#ED6B06;">1. Neuer Beitrag</td></tr><tr><td class="mobile-card-title" style="padding:0 22px 8px 22px;font-family:Arial, Helvetica, sans-serif;font-size:20px;line-height:27px;font-weight:bold;color:#4A4A4A;"><a href="https://www.thomas-krenn.com/de/wiki/Proxmox_VE_Cluster_einrichten" target="_blank" style="color:#4A4A4A;text-decoration:none;">Proxmox VE Cluster einrichten</a></td></tr><tr><td style="padding:0 22px 12px 22px;font-family:Arial, Helvetica, sans-serif;font-size:13px;line-height:20px;color:#6B6B6B;">Veröffentlicht am 02.07.2026 14:10 von AnnaSchmidt.</td></tr><tr><td style="padding:0 22px 12px 22px;font-family:Arial, Helvetica, sans-serif;font-size:12px;line-height:18px;color:#6B6B6B;">Kategorie: Virtualisierung</td></tr><tr><td style="padding:2px 22px 18px 22px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;"><tr><td bgcolor="#ED6B06" style="background:#ED6B06;border-radius:6px;"><a href="https://www.thomas-krenn.com/de/wiki/Proxmox_VE_Cluster_einrichten" target="_blank" style="display:inline-block;padding:12px 22px;font-family:Arial, Helvetica, sans-serif;font-size:14px;line-height:18px;font-weight:bold;color:#FFFFFF;text-decoration:none;">Zum Beitrag →</a></td></tr></table></td></tr></table></td></tr><tr><td style="padding:0 32px 16px 32px;" class="mobile-padding"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background:#FFFFFF;border:1px solid #E0E0E0;border-left:4px solid #ED6B06;border-radius:12px;"><tr><td style="padding:16px 22px 4px 22px;font-family:Arial, Helvetica, sans-serif;font-size:11px;line-height:16px;font-weight:bold;letter-spacing:.7px;text-transform:uppercase;color:#ED6B06;">2. Neuer Beitrag</td></tr><tr><td class="mobile-card-title" style="padding:0 22px 8px 22px;font-family:Arial, Helvetica, sans-serif;font-size:20px;line-height:27px;font-weight:bold;color:#4A4A4A;"><a href="https://www.thomas-krenn.com/de/wiki/RAID_Grundlagen_und_Konfiguration" target="_blank" style="color:#4A4A4A;text-decoration:none;">RAID Grundlagen und Konfiguration</a></td></tr><tr><td style="padding:0 22px 12px 22px;font-family:Arial, Helvetica, sans-serif;font-size:13px;line-height:20px;color:#6B6B6B;">Veröffentlicht am 01.07.2026 09:30 von MaxMuster.</td></tr><tr><td style="padding:0 22px 12px 22px;font-family:Arial, Helvetica, sans-serif;font-size:12px;line-height:18px;color:#6B6B6B;">Kategorie: Storage, Linux</td></tr><tr><td style="padding:2px 22px 18px 22px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;"><tr><td bgcolor="#ED6B06" style="background:#ED6B06;border-radius:6px;"><a href="https://www.thomas-krenn.com/de/wiki/RAID_Grundlagen_und_Konfiguration" target="_blank" style="display:inline-block;padding:12px 22px;font-family:Arial, Helvetica, sans-serif;font-size:14px;line-height:18px;font-weight:bold;color:#FFFFFF;text-decoration:none;">Zum Beitrag →</a></td></tr></table></td></tr></table></td></tr>
<tr><td style="padding:26px 32px 8px 32px;font-family:Arial, Helvetica, sans-serif;" class="mobile-padding"><div style="font-family:Arial, Helvetica, sans-serif;font-size:18px;line-height:24px;font-weight:bold;color:#4A4A4A;border-bottom:2px solid #4A4A4A;padding-bottom:8px;">Bearbeitete Artikel <span style="color:#4A4A4A;">(1)</span></div></td></tr><tr><td style="padding:0 32px 16px 32px;" class="mobile-padding"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background:#FFFFFF;border:1px solid #E0E0E0;border-left:4px solid #4A4A4A;border-radius:12px;"><tr><td style="padding:16px 22px 4px 22px;font-family:Arial, Helvetica, sans-serif;font-size:11px;line-height:16px;font-weight:bold;letter-spacing:.7px;text-transform:uppercase;color:#4A4A4A;">1. Aktualisierter Beitrag</td></tr><tr><td class="mobile-card-title" style="padding:0 22px 8px 22px;font-family:Arial, Helvetica, sans-serif;font-size:20px;line-height:27px;font-weight:bold;color:#4A4A4A;"><a href="https://www.thomas-krenn.com/de/wiki/ESXi_8_Update_Hinweise" target="_blank" style="color:#4A4A4A;text-decoration:none;">ESXi 8 Update Hinweise</a></td></tr><tr><td style="padding:0 22px 12px 22px;font-family:Arial, Helvetica, sans-serif;font-size:13px;line-height:20px;color:#6B6B6B;">Veröffentlicht am 03.07.2026 12:00 von TomWeber.</td></tr><tr><td style="padding:0 22px 12px 22px;font-family:Arial, Helvetica, sans-serif;font-size:12px;line-height:18px;color:#6B6B6B;">Kategorie: VMware</td></tr><tr><td style="padding:2px 22px 18px 22px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;"><tr><td bgcolor="#ED6B06" style="background:#ED6B06;border-radius:6px;"><a href="https://www.thomas-krenn.com/de/wiki/ESXi_8_Update_Hinweise" target="_blank" style="display:inline-block;padding:12px 22px;font-family:Arial, Helvetica, sans-serif;font-size:14px;line-height:18px;font-weight:bold;color:#FFFFFF;text-decoration:none;">Zum Beitrag →</a></td></tr></table></td></tr></table></td></tr>
<tr><td style="padding:24px 32px 0 32px;" class="mobile-padding"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:4px;font-family:Arial, Helvetica, sans-serif;">
<tr>
<td style="padding:0 0 10px;font-family:Arial, Helvetica, sans-serif;font-size:14px;line-height:20px;font-weight:bold;color:#4A4A4A;">Zusammenfassung</td>
</tr>
<tr>
<td style="font-family:Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial, Helvetica, sans-serif;">
<tr>
<td width="33%" style="padding:18px 12px;background:#FFF4ED;border:1px solid #E0E0E0;text-align:center;font-family:Arial, Helvetica, sans-serif;">
<div style="font-family:Arial, Helvetica, sans-serif;font-size:28px;line-height:32px;font-weight:bold;color:#ED6B06;">2</div>
<div style="margin-top:4px;font-family:Arial, Helvetica, sans-serif;font-size:12px;line-height:16px;color:#6B6B6B;">Neue Artikel</div>
</td>
<td width="4" style="font-size:0;line-height:0;">&nbsp;</td>
<td width="33%" style="padding:18px 12px;background:#EEEEEE;border:1px solid #E0E0E0;text-align:center;font-family:Arial, Helvetica, sans-serif;">
<div style="font-family:Arial, Helvetica, sans-serif;font-size:28px;line-height:32px;font-weight:bold;color:#4A4A4A;">1</div>
<div style="margin-top:4px;font-family:Arial, Helvetica, sans-serif;font-size:12px;line-height:16px;color:#6B6B6B;">Bearbeitet</div>
</td>
<td width="4" style="font-size:0;line-height:0;">&nbsp;</td>
<td width="33%" style="padding:18px 12px;background:#F5F5F5;border:1px solid #E0E0E0;text-align:center;font-family:Arial, Helvetica, sans-serif;">
<div style="font-family:Arial, Helvetica, sans-serif;font-size:28px;line-height:32px;font-weight:bold;color:#4A4A4A;">3</div>
<div style="margin-top:4px;font-family:Arial, Helvetica, sans-serif;font-size:12px;line-height:16px;color:#6B6B6B;">Gesamt</div>
</td>
</tr>
</table>
</td>
</tr>
</table></td></tr>
<tr><td style="padding:16px 32px 4px 32px;" class="mobile-padding"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background:#F5F5F5;border:1px solid #E0E0E0;border-radius:12px;"><tr><td style="padding:16px 18px 8px 18px;font-family:Arial, Helvetica, sans-serif;font-size:11px;line-height:15px;color:#4A4A4A;font-weight:bold;letter-spacing:.8px;text-transform:uppercase;">Redaktionsnotiz</td></tr><tr><td style="padding:0 18px 16px 18px;font-family:Arial, Helvetica, sans-serif;font-size:15px;line-height:24px;color:#1A1A1A;">Bitte gebt bis Freitag Feedback zu den neuen RAID-Artikeln.</td></tr></table></td></tr>
<tr>
<td align="center" style="padding:28px 32px 4px 32px;" class="mobile-padding">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;"><tr><td bgcolor="#ED6B06" style="background:#ED6B06;border-radius:6px;"><a href="https://www.thomas-krenn.com/de/wiki/Hauptseite" target="_blank" style="display:inline-block;padding:12px 22px;font-family:Arial, Helvetica, sans-serif;font-size:14px;line-height:18px;font-weight:bold;color:#FFFFFF;text-decoration:none;">Zum Thomas-Krenn-Wiki →</a></td></tr></table>
</td>
</tr>
<tr>
<td style="padding:24px 32px 28px 32px;" class="mobile-padding">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-family:Arial, Helvetica, sans-serif;font-size:15px;line-height:24px;color:#1A1A1A;">
Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.<br><br>
Viele Grüße<br>
Euer Thomas-Krenn-Wiki Team
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="background:#4A4A4A;padding:22px 20px;text-align:center;font-family:Arial, Helvetica, sans-serif;">
<div style="font-size:12px;line-height:19px;color:#EEF0F2;">
Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung<br>
Tel.: +49 8551 9150 0 | Fax: +49 8551 9150 55 |
<a href="https://www.thomas-krenn.com/de/wiki/Hauptseite" target="_blank" style="color:#ED6B06;text-decoration:underline;">thomas-krenn.com</a><br>
<span style="color:#AEB2B7;">Automatisch erstellter interner Newsletter &middot; Nur für den internen Gebrauch</span>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</center>
</body>
</html>