16 Commits

Author SHA1 Message Date
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
14 changed files with 1237 additions and 415 deletions

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`) - Login und Benutzerverwaltung mit RBAC (`admin`, `editor`, `reader`)
- Verpflichtender CSRF-Schutz auf allen POST-Formularen - Verpflichtender CSRF-Schutz auf allen POST-Formularen
- Filter in der Web-UI: - Filter in der Web-UI:
- Zeitraum in Tagen - Zeitraum in Tagen oder „Letzter Monat“
- Nur bearbeitete Artikel - Artikel-Auswahl: alle, nur neue oder nur bearbeitete Artikel
- Kategorie - 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`) - 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: - Export:
- Plain Text (für Outlook) - Plain Text (für Outlook)
- Raw HTML - Raw HTML
@@ -19,7 +25,7 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
- Verteilerlisten - Verteilerlisten
- tägliche Zeitplanung - tägliche Zeitplanung
- Versandprotokoll - Versandprotokoll
- Docker/Compose Betrieb - Docker/Compose Betrieb, inkl. automatischem Image-Build & Push in die Gitea Container Registry (per Versions-Tag)
## Start mit Docker ## Start mit Docker
@@ -44,6 +50,57 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
4. Zugriff: 4. Zugriff:
- [http://localhost:8080/login](http://localhost:8080/login) (oder dein `HOST_PORT`) - [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`).
## Sicherheits-Hinweise für Produktion ## Sicherheits-Hinweise für Produktion
- Reverse Proxy (z. B. Nginx/Traefik) mit TLS vor den Container setzen. - Reverse Proxy (z. B. Nginx/Traefik) mit TLS vor den Container setzen.

View File

@@ -1,5 +1,7 @@
from pathlib import Path from pathlib import Path
from pydantic import ValidationError
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, status from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, status
from fastapi.middleware.trustedhost import TrustedHostMiddleware from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse 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.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.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.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 ( from app.services.newsletter import (
DEFAULT_DISPLAY, DEFAULT_DISPLAY,
article_url, article_url,
@@ -27,6 +29,8 @@ from app.services.newsletter import (
create_subject, create_subject,
filter_articles, filter_articles,
parse_display_options, parse_display_options,
parse_highlights,
resolve_period,
split_articles_by_type, split_articles_by_type,
) )
from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter 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") @app.middleware("http")
async def add_security_headers(request: Request, call_next): 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: Response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY" response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin" response.headers["Referrer-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self'; script-src 'self';" # Die Newsletter-Vorschau läuft in einem srcdoc-iframe und erbt diese CSP.
if not request.cookies.get(CSRF_COOKIE_NAME): # E-Mail-HTML benötigt zwingend Inline-Styles; das Logo liegt auf einem externen https-Host.
response.set_cookie(CSRF_COOKIE_NAME, ensure_csrf_cookie(request), httponly=True, secure=cookie_secure(request), samesite="strict") 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 return response
@@ -99,7 +119,7 @@ def _base_context(request: Request, user: User, db: Session) -> dict:
active_nav = "users" active_nav = "users"
return { return {
"user": user, "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), "smtp": get_smtp_settings(db),
"active_nav": active_nav, "active_nav": active_nav,
} }
@@ -138,7 +158,7 @@ def login_page(request: Request, db: Session = Depends(get_db)):
request, request,
"login.html", "login.html",
{ {
"csrf_token": request.cookies.get(CSRF_COOKIE_NAME) or ensure_csrf_cookie(request), "csrf_token": ensure_csrf_cookie(request),
"error_message": None, "error_message": None,
}, },
) )
@@ -159,7 +179,7 @@ def login(
request, request,
"login.html", "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.", "error_message": "Sitzung abgelaufen. Bitte erneut anmelden.",
}, },
) )
@@ -169,7 +189,7 @@ def login(
request, request,
"login.html", "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.", "error_message": "Ungültige Zugangsdaten.",
}, },
) )
@@ -187,6 +207,63 @@ def logout(request: Request, csrf_token: str = Form(...)):
return response 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) @app.get("/dashboard", response_class=HTMLResponse)
async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
context = _base_context(request, current_user, db) context = _base_context(request, current_user, db)
@@ -197,13 +274,10 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use
"raw_html": "", "raw_html": "",
"outlook_html": "", "outlook_html": "",
"plain_text": "", "plain_text": "",
"filters": { "subject": "",
"days": 30, "filters": _empty_filters(),
"only_edited": False,
"category": "",
"display": DEFAULT_DISPLAY,
},
"wiki_error": None, "wiki_error": None,
"send_result": None,
}) })
logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all() logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all()
context["send_logs"] = logs context["send_logs"] = logs
@@ -214,86 +288,132 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use
async def dashboard_generate( async def dashboard_generate(
request: Request, request: Request,
csrf_token: str = Form(...), csrf_token: str = Form(...),
period: str = Form("days"),
days: int = Form(30), days: int = Form(30),
only_edited: str | None = Form(None), article_type: str = Form("all"),
category: str = Form(""), category: str = Form(""),
show_date: str | None = Form(None), show_date: str | None = Form(None),
show_user: str | None = Form(None), show_user: str | None = Form(None),
show_category: str | None = Form(None), show_category: str | None = Form(None),
editor_tip: str = Form(""),
highlights: str = Form(""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin), current_user: User = Depends(require_editor_or_admin),
): ):
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)
days = max(1, min(days, 90)) 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) display = parse_display_options(show_date, show_user, show_category)
wiki_error = None highlight_list = parse_highlights(highlights)
filtered: list = []
articles_new: list = [] gen = await _generate_newsletter(
articles_edited: list = [] period_mode=period,
plain_text = "" days=days,
raw_html = "" article_type=article_type,
outlook_html = "" category=category,
try: display=display,
articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag) editor_tip=editor_tip,
filtered = filter_articles(articles, category_filter=category or None) highlight_list=highlight_list,
articles_new, articles_edited = split_articles_by_type(filtered) )
title = create_subject(days, category) if not gen["wiki_error"]:
plain_text = create_plain_text(filtered, days, display, category) run_scheduled_send_if_due(db, gen["subject"], gen["raw_html"], gen["plain_text"])
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
context = _base_context(request, current_user, db) context = _base_context(request, current_user, db)
context.update( context.update(
{ {
"articles": filtered, "articles": gen["articles"],
"articles_new": articles_new, "articles_new": gen["articles_new"],
"articles_edited": articles_edited, "articles_edited": gen["articles_edited"],
"raw_html": raw_html, "raw_html": gen["raw_html"],
"outlook_html": outlook_html, "outlook_html": gen["outlook_html"],
"plain_text": plain_text, "plain_text": gen["plain_text"],
"subject": gen["subject"],
"filters": { "filters": {
"days": days, "days": days,
"only_edited": only_edited_flag, "period": period,
"article_type": article_type,
"category": category, "category": category,
"display": display, "display": display,
"editor_tip": editor_tip,
"highlights": highlights,
}, },
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(), "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) return _render(request, "dashboard.html", context)
@app.post("/newsletter/send-now") @app.post("/newsletter/send-now", response_class=HTMLResponse)
async def send_now( async def send_now(
request: Request, request: Request,
csrf_token: str = Form(...), csrf_token: str = Form(...),
period: str = Form("days"),
days: int = Form(30), days: int = Form(30),
only_edited: str | None = Form(None), article_type: str = Form("all"),
category: str = Form(""), category: str = Form(""),
show_date: str | None = Form(None), show_date: str | None = Form(None),
show_user: str | None = Form(None), show_user: str | None = Form(None),
show_category: str | None = Form(None), show_category: str | None = Form(None),
editor_tip: str = Form(""),
highlights: str = Form(""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_editor_or_admin), current_user: User = Depends(require_editor_or_admin),
): ):
validate_csrf(request, csrf_token) validate_csrf(request, csrf_token)
display = parse_display_options(show_date, show_user, show_category) display = parse_display_options(show_date, show_user, show_category)
only_edited_flag = only_edited == "true" if article_type not in ("all", "new", "edited"):
try: article_type = "all"
articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited_flag) highlight_list = parse_highlights(highlights)
except WikiFetchError as exc: days = max(1, min(days, 90))
raise HTTPException(status_code=502, detail=exc.message) from exc
filtered = filter_articles(articles, category_filter=category or None) gen = await _generate_newsletter(
title = create_subject(max(1, min(days, 90)), category) period_mode=period,
plain_text = create_plain_text(filtered, max(1, min(days, 90)), display, category) days=days,
raw_html = create_html(filtered, max(1, min(days, 90)), display, category) 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()] 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) status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False)
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) 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") @app.post("/admin/smtp")
@@ -325,14 +445,41 @@ def update_smtp_settings(
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND)
@app.get("/admin/users", response_class=HTMLResponse) def _users_context(request: Request, admin: User, db: Session, **extra) -> dict:
def users_page(request: Request, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
context = _base_context(request, admin, db) context = _base_context(request, admin, db)
context["users"] = db.query(User).order_by(User.created_at.desc()).all() 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( def create_user(
request: Request, request: Request,
csrf_token: str = Form(...), csrf_token: str = Form(...),
@@ -343,11 +490,32 @@ def create_user(
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
): ):
try:
validate_csrf(request, csrf_token) 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() existing = db.query(User).filter(User.email == payload.email).first()
if existing: 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( user = User(
email=payload.email, email=payload.email,
full_name=payload.full_name, full_name=payload.full_name,
@@ -358,4 +526,4 @@ def create_user(
) )
db.add(user) db.add(user)
db.commit() 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: 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.") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich.")
return current_user return current_user

View File

@@ -7,13 +7,21 @@ CSRF_COOKIE_NAME = "csrf_token"
CSRF_FORM_FIELD = "csrf_token" CSRF_FORM_FIELD = "csrf_token"
def ensure_csrf_cookie(request: Request) -> str: def generate_csrf_token() -> str:
token = request.cookies.get(CSRF_COOKIE_NAME)
if token:
return token
return secrets.token_urlsafe(32) 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: def validate_csrf(request: Request, form_token: str) -> None:
cookie_token = request.cookies.get(CSRF_COOKIE_NAME) 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): if not cookie_token or not form_token or not secrets.compare_digest(cookie_token, form_token):

View File

@@ -6,6 +6,7 @@ from urllib.parse import quote
WIKI_ARTICLE_BASE = "https://www.thomas-krenn.com/de/wiki/" WIKI_ARTICLE_BASE = "https://www.thomas-krenn.com/de/wiki/"
WIKI_HOME_URL = "https://www.thomas-krenn.com/de/wiki/Hauptseite" WIKI_HOME_URL = "https://www.thomas-krenn.com/de/wiki/Hauptseite"
ORG_NAME = "Thomas-Krenn.AG" ORG_NAME = "Thomas-Krenn.AG"
TK_LOGO_URL = "https://www.thomas-krenn.com/res/pics/tk_logo_340px.png"
LINE = "=" * 78 LINE = "=" * 78
SUBLINE = "-" * 78 SUBLINE = "-" * 78
@@ -14,6 +15,7 @@ FONT = "Arial, Helvetica, sans-serif"
COLOR_TK_ORANGE = "#ED6B06" COLOR_TK_ORANGE = "#ED6B06"
COLOR_TK_ORANGE_DARK = "#C95605" COLOR_TK_ORANGE_DARK = "#C95605"
COLOR_TK_ORANGE_LIGHT = "#FFF4ED" COLOR_TK_ORANGE_LIGHT = "#FFF4ED"
COLOR_TK_ORANGE_BORDER = "#F5C9A6"
COLOR_TK_GRAY_DARK = "#4A4A4A" COLOR_TK_GRAY_DARK = "#4A4A4A"
COLOR_TK_GRAY = "#6B6B6B" COLOR_TK_GRAY = "#6B6B6B"
COLOR_TK_GRAY_LIGHT = "#F5F5F5" COLOR_TK_GRAY_LIGHT = "#F5F5F5"
@@ -23,6 +25,7 @@ COLOR_HEADING = COLOR_TK_GRAY_DARK
COLOR_LINK = COLOR_TK_ORANGE COLOR_LINK = COLOR_TK_ORANGE
COLOR_BORDER = "#E0E0E0" COLOR_BORDER = "#E0E0E0"
COLOR_BG = COLOR_TK_GRAY_LIGHT COLOR_BG = COLOR_TK_GRAY_LIGHT
COLOR_PAGE_BG = "#ECEEF1"
COLOR_WHITE = "#FFFFFF" COLOR_WHITE = "#FFFFFF"
COLOR_ACCENT_NEW = COLOR_TK_ORANGE COLOR_ACCENT_NEW = COLOR_TK_ORANGE
COLOR_ACCENT_NEW_BG = COLOR_TK_ORANGE_LIGHT COLOR_ACCENT_NEW_BG = COLOR_TK_ORANGE_LIGHT
@@ -98,29 +101,123 @@ def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[st
return new_articles, edited_articles return new_articles, edited_articles
def create_subject(days: int, category: str = "") -> str: def create_subject(days: int, category: str = "", period_label: str | None = None) -> str:
base = f"Interner Wiki-Newsletter {ORG_NAME} ({days} Tage)" label = period_label or f"{days} Tage"
base = f"Interner Wiki-Newsletter {ORG_NAME} ({label})"
if category.strip(): if category.strip():
return f"{base} | Kategorie: {category.strip()}" return f"{base} | Kategorie: {category.strip()}"
return base return base
_MONTHS_DE = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
]
def _month_label(dt: datetime) -> str:
return f"{_MONTHS_DE[dt.month - 1]} {dt.year}"
def resolve_period(mode: str, days: int) -> dict[str, Any]:
"""Berechnet Start/Ende und Beschriftung für einen Zeitraum-Modus.
- "last_month": voriger Kalendermonat (z. B. 01.06.01.07.)
- sonst: die letzten N Tage
"""
now = datetime.now()
if mode == "last_month":
first_this_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
last_day_prev = first_this_month - timedelta(days=1)
start = last_day_prev.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
end = first_this_month
return {
"mode": "last_month",
"start": start,
"end": end,
"start_str": start.strftime("%d.%m.%Y"),
"end_str": end.strftime("%d.%m.%Y"),
"label": _month_label(start),
"days": max(1, (end - start).days),
}
days = max(1, min(days, 90))
end = now
start = now - timedelta(days=days)
return {
"mode": "days",
"start": start,
"end": end,
"start_str": start.strftime("%d.%m.%Y"),
"end_str": end.strftime("%d.%m.%Y"),
"label": f"{days} Tage",
"days": days,
}
def parse_highlights(raw: str | None) -> list[str]:
"""Zerlegt das Highlight-Eingabefeld (Titel pro Zeile) in eine Liste."""
if not raw:
return []
return [line.strip() for line in raw.splitlines() if line.strip()]
def _match_highlights(
articles: list[dict[str, Any]],
highlights: list[str] | None,
) -> list[dict[str, Any]]:
"""Ordnet Highlight-Titel den geladenen Artikeln zu (max. 3)."""
if not highlights:
return []
result: list[dict[str, Any]] = []
for wanted in highlights:
key = wanted.strip().lower()
if not key:
continue
for article in articles:
if article.get("title", "").strip().lower() == key:
result.append(article)
break
if len(result) >= 3:
break
return result
def _summary_sentence(total: int) -> str:
if total == 0:
return "Aktuell wurden keine neuen Wiki-Artikel gefunden."
if total == 1:
return "Es gibt einen neuen bzw. aktualisierten Wiki-Artikel."
return f"Es gibt {total} neue bzw. aktualisierte Wiki-Artikel."
# ---------------------------------------------------------------------------
# Plain-Text
# ---------------------------------------------------------------------------
def create_plain_text( def create_plain_text(
articles: list[dict[str, Any]], articles: list[dict[str, Any]],
days: int, days: int,
display: DisplayOptions | None = None, display: DisplayOptions | None = None,
category: str = "", category: str = "",
editor_tip: str = "",
highlights: list[str] | None = None,
period_label: str | None = None,
period: tuple[str, str] | None = None,
article_type: str = "all",
) -> str: ) -> str:
display = display or DEFAULT_DISPLAY display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles) new_articles, edited_articles = split_articles_by_type(articles)
period_start, period_end = _period_range(days) show_new = article_type in ("all", "new")
show_edited = article_type in ("all", "edited")
period_start, period_end = period if period else _period_range(days)
period_desc = period_label or f"{days} Tage"
created_at = datetime.now().strftime("%d.%m.%Y %H:%M") created_at = datetime.now().strftime("%d.%m.%Y %H:%M")
lines = [ lines = [
LINE, LINE,
f"{ORG_NAME.upper()} INTERNER WIKI-NEWSLETTER", f"{ORG_NAME.upper()} INTERNER WIKI-NEWSLETTER",
LINE, LINE,
f"Zeitraum: {period_start} bis {period_end} ({days} Tage)", f"Zeitraum: {period_start} bis {period_end} ({period_desc})",
f"Erstellt am: {created_at}", f"Erstellt am: {created_at}",
] ]
if category.strip(): if category.strip():
@@ -135,6 +232,14 @@ def create_plain_text(
] ]
) )
highlight_articles = _match_highlights(new_articles + edited_articles, highlights)
if highlight_articles:
lines.extend([SUBLINE, "TOP HIGHLIGHTS", SUBLINE, ""])
for idx, item in enumerate(highlight_articles, start=1):
lines.append(f" {idx}. {item.get('title', 'Ohne Titel')}")
lines.append(f" {_article_url(item.get('title', ''))}")
lines.append("")
if not new_articles and not edited_articles: if not new_articles and not edited_articles:
lines.extend( lines.extend(
[ [
@@ -143,13 +248,16 @@ def create_plain_text(
SUBLINE, SUBLINE,
"Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", "Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.",
"", "",
_plain_footer(new_articles, edited_articles),
] ]
) )
lines.extend(_plain_editor_tip(editor_tip))
lines.append(_plain_footer(new_articles, edited_articles))
return "\n".join(lines).strip() return "\n".join(lines).strip()
if show_new:
lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum.")) lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum."))
lines.append("") lines.append("")
if show_edited:
lines.extend( lines.extend(
_plain_section( _plain_section(
"BEARBEITETE ARTIKEL", "BEARBEITETE ARTIKEL",
@@ -159,111 +267,334 @@ def create_plain_text(
) )
) )
lines.append("") lines.append("")
lines.extend(_plain_editor_tip(editor_tip))
lines.append(_plain_footer(new_articles, edited_articles)) lines.append(_plain_footer(new_articles, edited_articles))
return "\n".join(lines).strip() return "\n".join(lines).strip()
def _plain_editor_tip(editor_tip: str) -> list[str]:
text = (editor_tip or "").strip()
if not text:
return []
lines = [SUBLINE, "REDAKTIONSNOTIZ", SUBLINE, ""]
lines.extend(f" {line}" for line in text.splitlines())
lines.append("")
return lines
# ---------------------------------------------------------------------------
# HTML (Standard, für SMTP-Versand & Quelltext)
# ---------------------------------------------------------------------------
def create_html( def create_html(
articles: list[dict[str, Any]], articles: list[dict[str, Any]],
days: int, days: int,
display: DisplayOptions | None = None, display: DisplayOptions | None = None,
category: str = "", category: str = "",
editor_tip: str = "",
highlights: list[str] | None = None,
period_label: str | None = None,
period: tuple[str, str] | None = None,
article_type: str = "all",
) -> str: ) -> str:
display = display or DEFAULT_DISPLAY display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles) new_articles, edited_articles = split_articles_by_type(articles)
period_start, period_end = _period_range(days) show_new = article_type in ("all", "new")
created_at = datetime.now().strftime("%d.%m.%Y %H:%M") show_edited = article_type in ("all", "edited")
subject = escape(create_subject(days, category)) period_start, period_end = period if period else _period_range(days)
total = len(new_articles) + len(edited_articles)
subject = escape(create_subject(days, category, period_label))
preheader = escape(_summary_sentence(total))
period_label = escape(f"{period_start} {period_end}")
summary_text = escape(_summary_sentence(total))
highlight_articles = _match_highlights(new_articles + edited_articles, highlights)
highlights_section = _html_highlights(highlight_articles)
if not new_articles and not edited_articles: if not new_articles and not edited_articles:
body = f'<p style="margin:0;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_MUTED};">Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.</p>' body = _html_empty_card("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.")
else: else:
body = "\n".join( sections = []
[ if show_new:
_html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"), sections.append(_html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"))
_html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited"), if show_edited:
] sections.append(_html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited"))
) body = "\n".join(sections)
summary = _html_summary(new_articles, edited_articles) summary_section = (
intro = escape(_intro_sentence(period_start, period_end)) f'<tr><td style="padding:24px 32px 0 32px;" class="mobile-padding">{_html_summary(new_articles, edited_articles)}</td></tr>'
)
editor_tip_section = _html_editor_tip(editor_tip)
category_hint = ""
if category.strip():
category_hint = ( category_hint = (
f'<tr><td style="padding:0 32px 0;font-family:{FONT};"><p style="margin:0 0 16px;font-family:{FONT};font-size:13px;line-height:20px;color:{COLOR_MUTED};"><strong style="color:{COLOR_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}</p></td></tr>' f'<tr><td style="padding:0 32px 4px 32px;font-family:{FONT};" class="mobile-padding">'
if category.strip() f'<p style="margin:0;font-family:{FONT};font-size:13px;line-height:20px;color:{COLOR_MUTED};">'
else "" f'<strong style="color:{COLOR_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}</p></td></tr>'
) )
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml"> <html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{subject}</title> <title>{subject}</title>
<style type="text/css">
html, body {{ margin:0 !important; padding:0 !important; width:100% !important; background:{COLOR_PAGE_BG}; }}
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]> <!--[if mso]>
<style type="text/css"> <style type="text/css">
body, table, td, p, a, li, h1, h2, h3, span, strong {{ font-family: Arial, sans-serif !important; }} body, table, td, p, a, li, h1, h2, h3, span, strong {{ font-family: Arial, sans-serif !important; }}
</style> </style>
<![endif]--> <![endif]-->
</head> </head>
<body style="margin:0;padding:0;background:{COLOR_BG};font-family:{FONT};color:{COLOR_TEXT};"> <body style="margin:0;padding:0;background:{COLOR_PAGE_BG};font-family:{FONT};color:{COLOR_TEXT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{COLOR_BG};font-family:{FONT};"> <div class="preheader">{preheader}&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;</div>
<center role="article" aria-roledescription="email" lang="de" style="width:100%;background:{COLOR_PAGE_BG};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{COLOR_PAGE_BG}" style="width:100%;background:{COLOR_PAGE_BG};">
<tr> <tr>
<td align="center" style="padding:28px 16px;font-family:{FONT};"> <td align="center" style="padding:22px 10px;">
<table role="presentation" width="680" cellpadding="0" cellspacing="0" border="0" style="width:680px;max-width:680px;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};font-family:{FONT};"> <table role="presentation" width="700" cellpadding="0" cellspacing="0" border="0" class="email-container" style="width:100%;max-width:700px;background:{COLOR_WHITE};border-top:4px solid {COLOR_TK_ORANGE};">
<tr> <tr>
<td style="height:5px;background:{COLOR_TK_ORANGE};font-size:0;line-height:0;">&nbsp;</td> <td align="center" style="padding:24px 32px 18px 32px;background:{COLOR_TK_GRAY_DARK};" class="mobile-padding">
</tr> <img src="{TK_LOGO_URL}" width="190" alt="Thomas-Krenn Logo" style="display:block;margin:0 auto;border:0;outline:none;text-decoration:none;height:auto;">
<tr>
<td style="background:{COLOR_TK_GRAY_DARK};padding:30px 36px 28px;font-family:{FONT};">
<p style="margin:0;font-family:{FONT};font-size:11px;line-height:16px;letter-spacing:1.2px;text-transform:uppercase;color:{COLOR_TK_ORANGE};">Interner Newsletter</p>
<h1 style="margin:10px 0 0;font-family:{FONT};font-size:28px;line-height:34px;font-weight:bold;color:{COLOR_WHITE};">Thomas-Krenn Wiki Update</h1>
<p style="margin:10px 0 0;font-family:{FONT};font-size:14px;line-height:21px;color:#CCCCCC;">{escape(ORG_NAME)}</p>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding:28px 36px 0;font-family:{FONT};"> <td style="padding:0 32px;" class="mobile-padding">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{COLOR_TK_ORANGE_LIGHT};border-left:4px solid {COLOR_TK_ORANGE};font-family:{FONT};"> <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background:{COLOR_TK_GRAY_DARK};">
<tr>
<td style="padding:28px 26px;font-family:{FONT};">
<div style="font-size:12px;line-height:16px;font-weight:bold;color:{COLOR_TK_ORANGE};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:{COLOR_WHITE};margin-top:10px;">Die neuesten Wiki-Artikel</div>
<div style="font-size:16px;line-height:25px;color:#DDDDDD;margin-top:10px;">{period_label} &nbsp;|&nbsp; {summary_text}</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:{COLOR_TK_ORANGE_LIGHT};border-left:4px solid {COLOR_TK_ORANGE};">
<tr> <tr>
<td style="padding:18px 20px;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};"> <td style="padding:18px 20px;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">
<p style="margin:0 0 10px;font-family:{FONT};font-size:15px;line-height:24px;">Liebe Kolleginnen und Kollegen,</p> <p style="margin:0 0 10px;font-family:{FONT};">Servus,</p>
<p style="margin:0;font-family:{FONT};font-size:15px;line-height:24px;">{intro}</p> <p style="margin:0;font-family:{FONT};">hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {escape(period_start)} bis {escape(period_end)}.</p>
</td> </td>
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>
{category_hint} {category_hint}
<tr> {highlights_section}
<td style="padding:8px 36px 0;font-family:{FONT};">
<p style="margin:0 0 18px;font-family:{FONT};font-size:12px;line-height:18px;color:{COLOR_MUTED};">Erstellt am {created_at}</p>
</td>
</tr>
<tr>
<td style="padding:0 36px 30px;font-family:{FONT};">
{body} {body}
{summary} {summary_section}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:32px auto 0;font-family:{FONT};"> {editor_tip_section}
<tr> <tr>
<td align="center" style="background:{COLOR_TK_ORANGE};padding:14px 28px;font-family:{FONT};"> <td align="center" style="padding:28px 32px 4px 32px;" class="mobile-padding">
<a href="{WIKI_HOME_URL}" style="font-family:{FONT};font-size:14px;line-height:20px;font-weight:bold;color:{COLOR_WHITE};text-decoration:none;">Zum Thomas-Krenn-Wiki &rarr;</a> {_cta_button(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki ")}
</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:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">
Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.<br><br>
Viele Grüße<br>
Euer Thomas-Krenn-Wiki Team
</td> </td>
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>
<tr> <tr>
<td style="padding:20px 36px;background:{COLOR_BG};border-top:1px solid {COLOR_BORDER};font-family:{FONT};font-size:11px;line-height:17px;color:{COLOR_MUTED};text-align:center;"> <td style="background:{COLOR_TK_GRAY_DARK};padding:22px 20px;text-align:center;font-family:{FONT};">
Automatisch erstellter interner Newsletter &middot; Nur für den Gebrauch bei {escape(ORG_NAME)} <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="{WIKI_HOME_URL}" target="_blank" style="color:{COLOR_TK_ORANGE};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> </td>
</tr> </tr>
</table> </table>
</td> </td>
</tr> </tr>
</table> </table>
</center>
</body> </body>
</html>""" </html>"""
def _cta_button(url: str, label: str) -> str:
return (
f'<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;">'
f'<tr><td bgcolor="{COLOR_TK_ORANGE}" style="background:{COLOR_TK_ORANGE};border-radius:6px;">'
f'<a href="{escape(url)}" target="_blank" style="display:inline-block;padding:12px 22px;font-family:{FONT};'
f'font-size:14px;line-height:18px;font-weight:bold;color:{COLOR_WHITE};text-decoration:none;">{escape(label)}</a>'
f'</td></tr></table>'
)
def _html_empty_card(text: str) -> str:
return (
f'<tr><td style="padding:8px 32px 8px 32px;" class="mobile-padding">'
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};border-radius:12px;">'
f'<tr><td style="padding:20px 22px;font-family:{FONT};font-size:15px;line-height:23px;color:{COLOR_TEXT};">'
f'{escape(text)}</td></tr></table></td></tr>'
)
def _html_highlights(items: list[dict[str, Any]]) -> str:
if not items:
return ""
rows = ""
for idx, item in enumerate(items[:3], start=1):
url = escape(_article_url(item.get("title", "")))
title = escape(item.get("title", "Ohne Titel"))
rows += (
f'<tr><td style="padding:0 0 8px 0;font-family:{FONT};font-size:15px;line-height:22px;color:{COLOR_TEXT};">'
f'<span style="display:inline-block;min-width:22px;font-weight:bold;color:{COLOR_TK_ORANGE};">{idx}.</span> '
f'<a href="{url}" target="_blank" style="color:{COLOR_TEXT};text-decoration:none;">{title}</a></td></tr>'
)
return (
f'<tr><td style="padding:8px 32px 4px 32px;" class="mobile-padding">'
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;background:{COLOR_TK_ORANGE_LIGHT};border:1px solid {COLOR_TK_ORANGE_BORDER};border-radius:12px;">'
f'<tr><td style="padding:16px 18px 8px 18px;font-family:{FONT};font-size:11px;line-height:15px;'
f'color:{COLOR_TK_ORANGE_DARK};font-weight:bold;letter-spacing:.8px;text-transform:uppercase;">Top Highlights</td></tr>'
f'<tr><td style="padding:0 18px 12px 18px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">{rows}</table></td></tr>'
f'</table></td></tr>'
)
def _html_editor_tip(editor_tip: str) -> str:
text = (editor_tip or "").strip()
if not text:
return ""
body = escape(text).replace("\n", "<br>")
return (
f'<tr><td style="padding:16px 32px 4px 32px;" class="mobile-padding">'
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;background:{COLOR_TK_GRAY_LIGHT};border:1px solid {COLOR_BORDER};border-radius:12px;">'
f'<tr><td style="padding:16px 18px 8px 18px;font-family:{FONT};font-size:11px;line-height:15px;'
f'color:{COLOR_HEADING};font-weight:bold;letter-spacing:.8px;text-transform:uppercase;">Redaktionsnotiz</td></tr>'
f'<tr><td style="padding:0 18px 16px 18px;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">{body}</td></tr>'
f'</table></td></tr>'
)
def _html_section(
heading: str,
items: list[dict[str, Any]],
display: DisplayOptions,
empty_text: str,
kind: str = "edited",
) -> str:
accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT
heading_html = (
f'<tr><td style="padding:26px 32px 8px 32px;font-family:{FONT};" class="mobile-padding">'
f'<div style="font-family:{FONT};font-size:18px;line-height:24px;font-weight:bold;color:{COLOR_HEADING};'
f'border-bottom:2px solid {accent};padding-bottom:8px;">{escape(heading)} '
f'<span style="color:{accent};">({len(items)})</span></div></td></tr>'
)
if not items:
return heading_html + (
f'<tr><td style="padding:8px 32px 4px 32px;font-family:{FONT};font-size:14px;line-height:22px;'
f'color:{COLOR_MUTED};" class="mobile-padding">{escape(empty_text)}</td></tr>'
)
cards = "".join(_html_article_card(idx, item, display, kind) for idx, item in enumerate(items, start=1))
return heading_html + cards
def _html_article_card(idx: int, item: dict[str, Any], display: DisplayOptions, kind: str) -> str:
accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT
eyebrow = "Neuer Beitrag" if kind == "new" else "Aktualisierter Beitrag"
title = escape(item.get("title", "Ohne Titel"))
url = escape(_article_url(item.get("title", "")))
meta_bits: list[str] = []
if display["show_date"]:
meta_bits.append(f"am {_format_ts(item.get('timestamp'))}")
if display["show_user"]:
meta_bits.append(f"von {escape(str(item.get('user', '-')))}")
meta_html = ""
if meta_bits:
meta_html = (
f'<tr><td style="padding:0 22px 12px 22px;font-family:{FONT};font-size:13px;line-height:20px;'
f'color:{COLOR_MUTED};">Veröffentlicht {" ".join(meta_bits)}.</td></tr>'
)
cat_html = ""
if display["show_category"]:
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
cat_html = (
f'<tr><td style="padding:0 22px 12px 22px;font-family:{FONT};font-size:12px;line-height:18px;'
f'color:{COLOR_MUTED};">Kategorie: {escape(cat)}</td></tr>'
)
return (
f'<tr><td style="padding:0 32px 16px 32px;" class="mobile-padding">'
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};border-left:4px solid {accent};border-radius:12px;">'
f'<tr><td style="padding:16px 22px 4px 22px;font-family:{FONT};font-size:11px;line-height:16px;font-weight:bold;'
f'letter-spacing:.7px;text-transform:uppercase;color:{accent};">{idx}. {eyebrow}</td></tr>'
f'<tr><td class="mobile-card-title" style="padding:0 22px 8px 22px;font-family:{FONT};font-size:20px;'
f'line-height:27px;font-weight:bold;color:{COLOR_HEADING};">'
f'<a href="{url}" target="_blank" style="color:{COLOR_HEADING};text-decoration:none;">{title}</a></td></tr>'
f'{meta_html}{cat_html}'
f'<tr><td style="padding:2px 22px 18px 22px;">{_cta_button(url, "Zum Beitrag →")}</td></tr>'
f'</table></td></tr>'
)
def _html_summary(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str:
total = len(new_articles) + len(edited_articles)
return f"""<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:4px;font-family:{FONT};">
<tr>
<td style="padding:0 0 10px;font-family:{FONT};font-size:14px;line-height:20px;font-weight:bold;color:{COLOR_HEADING};">Zusammenfassung</td>
</tr>
<tr>
<td style="font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
<tr>
<td width="33%" style="padding:18px 12px;background:{COLOR_ACCENT_NEW_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_ACCENT_NEW};">{len(new_articles)}</div>
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Neue Artikel</div>
</td>
<td width="4" style="font-size:0;line-height:0;">&nbsp;</td>
<td width="33%" style="padding:18px 12px;background:{COLOR_ACCENT_EDIT_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_ACCENT_EDIT};">{len(edited_articles)}</div>
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Bearbeitet</div>
</td>
<td width="4" style="font-size:0;line-height:0;">&nbsp;</td>
<td width="33%" style="padding:18px 12px;background:{COLOR_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_HEADING};">{total}</div>
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Gesamt</div>
</td>
</tr>
</table>
</td>
</tr>
</table>"""
# ---------------------------------------------------------------------------
# Outlook/Word-kompatibles HTML (UI-Vorschau & Copy-Paste in Outlook)
# ---------------------------------------------------------------------------
OUTLOOK_FONT = "Arial, sans-serif" OUTLOOK_FONT = "Arial, sans-serif"
OUTLOOK_TEXT = COLOR_TEXT OUTLOOK_TEXT = COLOR_TEXT
OUTLOOK_MUTED = COLOR_TK_GRAY OUTLOOK_MUTED = COLOR_TK_GRAY
@@ -315,52 +646,66 @@ def _outlook_font(
) )
def _outlook_link(title: str, url: str, *, bold: bool = True) -> str: def _outlook_link(title: str, url: str, *, bold: bool = True, color: str = OUTLOOK_LINK, size: str = "2") -> str:
weight = "<b>" if bold else "" weight = "<b>" if bold else ""
weight_end = "</b>" if bold else "" weight_end = "</b>" if bold else ""
return ( return (
f'<a href="{escape(url)}" ' f'<a href="{escape(url)}" '
f'style="color:{OUTLOOK_LINK} !important;text-decoration:none !important;mso-style-priority:100 !important;">' f'style="color:{color} !important;text-decoration:none !important;mso-style-priority:100 !important;">'
f'<span style="color:{OUTLOOK_LINK} !important;" data-ogsc="{OUTLOOK_LINK}">' f'<span style="color:{color} !important;" data-ogsc="{color}">'
f'<font face="Arial" color="{OUTLOOK_LINK}" size="2">' f'<font face="Arial" color="{color}" size="{size}">'
f"{weight}{escape(title)}{weight_end}" f"{weight}{escape(title)}{weight_end}"
f"</font></span></a>" f"</font></span></a>"
) )
def _outlook_cta(url: str, label: str) -> str:
return (
f'<table cellpadding="0" cellspacing="0" border="0"><tr>'
f'<td bgcolor="{OUTLOOK_ORANGE}" style="background-color:{OUTLOOK_ORANGE} !important;padding:9px 16px;" data-ogsb="{OUTLOOK_ORANGE}">'
f'{_outlook_link(label, url, color="#FFFFFF")}'
f'</td></tr></table>'
)
def create_outlook_html( def create_outlook_html(
articles: list[dict[str, Any]], articles: list[dict[str, Any]],
days: int, days: int,
display: DisplayOptions | None = None, display: DisplayOptions | None = None,
category: str = "", category: str = "",
editor_tip: str = "",
highlights: list[str] | None = None,
period_label: str | None = None,
period: tuple[str, str] | None = None,
article_type: str = "all",
) -> str: ) -> str:
"""Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook).""" """Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook)."""
display = display or DEFAULT_DISPLAY display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles) new_articles, edited_articles = split_articles_by_type(articles)
period_start, period_end = _period_range(days) show_new = article_type in ("all", "new")
created_at = datetime.now().strftime("%d.%m.%Y %H:%M") show_edited = article_type in ("all", "edited")
intro = _intro_sentence(period_start, period_end) period_start, period_end = period if period else _period_range(days)
subject = escape(create_subject(days, category)) total = len(new_articles) + len(edited_articles)
subject = escape(create_subject(days, category, period_label))
period_label = f"{period_start} {period_end}"
summary_text = _summary_sentence(total)
highlight_articles = _match_highlights(new_articles + edited_articles, highlights)
highlights_rows = _outlook_highlights(highlight_articles)
if not new_articles and not edited_articles: if not new_articles and not edited_articles:
body_rows = f"""<tr> body_rows = f"""<tr>
<td {_outlook_td_attrs(colspan=2, extra_style="padding:12px 0;")}> <td {_outlook_td_attrs(extra_style="padding:12px 0;")}>
{_outlook_font("Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)} {_outlook_font("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)}
</td> </td>
</tr>""" </tr>"""
else: else:
body_rows = "\n".join( sections = []
[ if show_new:
_outlook_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"), sections.append(_outlook_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"))
_outlook_section( if show_edited:
"Bearbeitete Artikel", sections.append(_outlook_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited"))
edited_articles, body_rows = "\n".join(sections)
display,
"Keine bearbeiteten Artikel im Zeitraum.",
kind="edited",
),
]
)
category_block = "" category_block = ""
if category.strip(): if category.strip():
@@ -371,14 +716,15 @@ def create_outlook_html(
f"</p>" f"</p>"
) )
total = len(new_articles) + len(edited_articles)
summary_row = f"""<tr> summary_row = f"""<tr>
<td {_outlook_td_attrs(colspan=2, extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}> <td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
{_outlook_font("Zusammenfassung: ", color=OUTLOOK_HEADING, bold=True, size="1")} {_outlook_font("Zusammenfassung: ", color=OUTLOOK_HEADING, bold=True, size="1")}
{_outlook_font(f"{len(new_articles)} neu · {len(edited_articles)} bearbeitet · {total} gesamt", color=OUTLOOK_MUTED, size="1")} {_outlook_font(f"{len(new_articles)} neu · {len(edited_articles)} bearbeitet · {total} gesamt", color=OUTLOOK_MUTED, size="1")}
</td> </td>
</tr>""" </tr>"""
editor_tip_row = _outlook_editor_tip(editor_tip)
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word"> <html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">
<head> <head>
@@ -407,33 +753,39 @@ def create_outlook_html(
</style> </style>
</head> </head>
<body bgcolor="{OUTLOOK_WHITE}" style="margin:0;padding:16px;background-color:{OUTLOOK_WHITE} !important;color:{OUTLOOK_TEXT} !important;" data-ogsb="{OUTLOOK_WHITE}"> <body bgcolor="{OUTLOOK_WHITE}" style="margin:0;padding:16px;background-color:{OUTLOOK_WHITE} !important;color:{OUTLOOK_TEXT} !important;" data-ogsb="{OUTLOOK_WHITE}">
<table width="600" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="{OUTLOOK_WHITE}" style="width:600px;background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}"> <table width="640" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="{OUTLOOK_WHITE}" style="width:640px;background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}">
<tr> <tr>
<td {_outlook_td_attrs(bg=OUTLOOK_ORANGE, extra_style="height:5px;font-size:0;line-height:0;")}>&nbsp;</td> <td {_outlook_td_attrs(bg=OUTLOOK_ORANGE, extra_style="height:5px;font-size:0;line-height:0;")}>&nbsp;</td>
</tr> </tr>
<tr> <tr>
<td {_outlook_td_attrs(bg=OUTLOOK_GRAY_DARK, extra_style="padding:16px 20px;")}> <td {_outlook_td_attrs(bg=OUTLOOK_GRAY_DARK, extra_style="padding:16px 20px;text-align:center;")} align="center">
{_outlook_font("Interner Newsletter", color=OUTLOOK_ORANGE, size="1")}<br> <img src="{TK_LOGO_URL}" width="180" alt="Thomas-Krenn Logo" style="display:block;margin:0 auto;border:0;height:auto;">
{_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_WHITE, size="5", bold=True)}<br> </td>
{_outlook_font(ORG_NAME, color="#CCCCCC", size="2")} </tr>
<tr>
<td {_outlook_td_attrs(bg=OUTLOOK_GRAY_DARK, extra_style="padding:18px 20px;")}>
{_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_ORANGE, size="1", bold=True)}<br>
{_outlook_font("Die neuesten Wiki-Artikel", color=OUTLOOK_WHITE, size="5", bold=True)}<br>
{_outlook_font(f"{period_label} | {summary_text}", color="#DDDDDD", size="2")}
</td> </td>
</tr> </tr>
<tr> <tr>
<td {_outlook_td_attrs(extra_style="padding:20px;")}> <td {_outlook_td_attrs(extra_style="padding:20px;")}>
<p style="margin:0 0 8px;">{_outlook_font("Liebe Kolleginnen und Kollegen,")}</p> <p style="margin:0 0 8px;">{_outlook_font("Servus,")}</p>
<p style="margin:0 0 12px;">{_outlook_font(intro)}</p> <p style="margin:0 0 16px;">{_outlook_font(f"hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {period_start} bis {period_end}.")}</p>
<p style="margin:0 0 16px;">{_outlook_font(f"Erstellt am: {created_at}", color=OUTLOOK_MUTED, size="1")}</p>
{category_block} {category_block}
{highlights_rows}
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}"> <table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}">
{body_rows} {body_rows}
{summary_row} {summary_row}
{editor_tip_row}
<tr> <tr>
<td {_outlook_td_attrs(colspan=2, extra_style="padding:20px 0 0;")}> <td {_outlook_td_attrs(extra_style="padding:20px 0 0;")}>
{_outlook_link("Zum Thomas-Krenn-Wiki →", WIKI_HOME_URL)} {_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
</td> </td>
</tr> </tr>
<tr> <tr>
<td {_outlook_td_attrs(colspan=2, extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}> <td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
{_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")} {_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")}
</td> </td>
</tr> </tr>
@@ -445,6 +797,42 @@ def create_outlook_html(
</html>""" </html>"""
def _outlook_highlights(items: list[dict[str, Any]]) -> str:
if not items:
return ""
rows = ""
for idx, item in enumerate(items[:3], start=1):
url = _article_url(item.get("title", ""))
rows += (
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_ORANGE_LIGHT, extra_style="padding:3px 0;")}>'
f'{_outlook_font(f"{idx}. ", color=OUTLOOK_ORANGE, bold=True)}'
f'{_outlook_link(item.get("title", "Ohne Titel"), url, color=OUTLOOK_TEXT, bold=False)}'
f'</td></tr>'
)
return (
f'<table width="100%" cellpadding="0" cellspacing="0" border="0" '
f'bgcolor="{COLOR_TK_ORANGE_LIGHT}" style="background-color:{COLOR_TK_ORANGE_LIGHT} !important;'
f'border:1px solid {COLOR_TK_ORANGE_BORDER};margin:0 0 12px;" data-ogsb="{COLOR_TK_ORANGE_LIGHT}">'
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_ORANGE_LIGHT, extra_style="padding:12px 14px 6px;")}>'
f'{_outlook_font("TOP HIGHLIGHTS", color=COLOR_TK_ORANGE_DARK, size="1", bold=True)}</td></tr>'
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_ORANGE_LIGHT, extra_style="padding:0 14px 12px;")}>'
f'<table width="100%" cellpadding="0" cellspacing="0" border="0">{rows}</table></td></tr>'
f'</table>'
)
def _outlook_editor_tip(editor_tip: str) -> str:
text = (editor_tip or "").strip()
if not text:
return ""
body = "<br>".join(_outlook_font(line) for line in text.splitlines())
return (
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_GRAY_LIGHT, extra_style=f"padding:14px 16px;margin-top:16px;border:1px solid {OUTLOOK_BORDER};")}>'
f'{_outlook_font("Redaktionsnotiz", color=OUTLOOK_HEADING, size="1", bold=True)}<br>'
f'{body}</td></tr>'
)
def _outlook_meta_line(item: dict[str, Any], display: DisplayOptions) -> str: def _outlook_meta_line(item: dict[str, Any], display: DisplayOptions) -> str:
parts: list[str] = [] parts: list[str] = []
if display["show_date"]: if display["show_date"]:
@@ -457,28 +845,6 @@ def _outlook_meta_line(item: dict[str, Any], display: DisplayOptions) -> str:
return " | ".join(parts) return " | ".join(parts)
def _outlook_article_rows(items: list[dict[str, Any]], display: DisplayOptions) -> str:
rows: list[str] = []
for idx, item in enumerate(items, 1):
title = item.get("title", "Ohne Titel")
url = _article_url(item.get("title", ""))
meta = _outlook_meta_line(item, display)
meta_html = ""
if meta:
meta_html = f"<br>{_outlook_font(meta, color=OUTLOOK_MUTED, size='1')}"
rows.append(
f"""<tr>
<td {_outlook_td_attrs(width="28", valign="top", extra_style=f"padding:8px 8px 10px 0;border-bottom:1px solid {OUTLOOK_BORDER};")}>
{_outlook_font(f"{idx}.")}
</td>
<td {_outlook_td_attrs(valign="top", extra_style=f"padding:8px 0 10px;border-bottom:1px solid {OUTLOOK_BORDER};")}>
{_outlook_link(title, url)}{meta_html}
</td>
</tr>"""
)
return "\n".join(rows)
def _outlook_section( def _outlook_section(
heading: str, heading: str,
items: list[dict[str, Any]], items: list[dict[str, Any]],
@@ -488,19 +854,47 @@ def _outlook_section(
) -> str: ) -> str:
accent = OUTLOOK_ORANGE if kind == "new" else OUTLOOK_GRAY_DARK accent = OUTLOOK_ORANGE if kind == "new" else OUTLOOK_GRAY_DARK
header = f"""<tr> header = f"""<tr>
<td {_outlook_td_attrs(colspan=2, extra_style=f"padding:18px 0 8px;border-bottom:2px solid {accent};")}> <td {_outlook_td_attrs(extra_style=f"padding:18px 0 8px;border-bottom:2px solid {accent};")}>
{_outlook_font(f"{heading} ({len(items)})", color=accent, size="3", bold=True)} {_outlook_font(f"{heading} ({len(items)})", color=accent, size="3", bold=True)}
</td> </td>
</tr>""" </tr>"""
if not items: if not items:
return header + f"""<tr> return header + f"""<tr>
<td {_outlook_td_attrs(colspan=2, extra_style="padding:8px 0;")}> <td {_outlook_td_attrs(extra_style="padding:8px 0;")}>
{_outlook_font(empty_text, color=OUTLOOK_MUTED)} {_outlook_font(empty_text, color=OUTLOOK_MUTED)}
</td> </td>
</tr>""" </tr>"""
return header + _outlook_article_rows(items, display) cards = "\n".join(_outlook_article_card(idx, item, display, kind) for idx, item in enumerate(items, start=1))
return header + cards
def _outlook_article_card(idx: int, item: dict[str, Any], display: DisplayOptions, kind: str) -> str:
accent = OUTLOOK_ORANGE if kind == "new" else OUTLOOK_GRAY_DARK
eyebrow = "NEUER BEITRAG" if kind == "new" else "AKTUALISIERTER BEITRAG"
title = item.get("title", "Ohne Titel")
url = _article_url(item.get("title", ""))
meta = _outlook_meta_line(item, display)
meta_html = f'<br>{_outlook_font(meta, color=OUTLOOK_MUTED, size="1")}' if meta else ""
return f"""<tr>
<td {_outlook_td_attrs(extra_style="padding:8px 0;")}>
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;border:1px solid {OUTLOOK_BORDER};" data-ogsb="{OUTLOOK_WHITE}">
<tr>
<td {_outlook_td_attrs(bg=accent, width="6", extra_style="font-size:0;line-height:0;width:6px;")}>&nbsp;</td>
<td {_outlook_td_attrs(extra_style="padding:16px 20px;")}>
{_outlook_font(f"{idx}. {eyebrow}", color=accent, size="1", bold=True)}<br>
<span style="display:inline-block;margin:6px 0 2px;">{_outlook_link(title, url, color=OUTLOOK_TEXT, size="4")}</span>{meta_html}<br>
<span style="display:inline-block;margin-top:12px;">{_outlook_cta(url, "Zum Beitrag →")}</span>
</td>
</tr>
</table>
</td>
</tr>"""
# ---------------------------------------------------------------------------
# Plain-Text-Helfer
# ---------------------------------------------------------------------------
def _plain_section( def _plain_section(
heading: str, heading: str,
items: list[dict[str, Any]], items: list[dict[str, Any]],
@@ -551,148 +945,16 @@ def _plain_footer(new_articles: list[dict[str, Any]], edited_articles: list[dict
f"Wiki: {WIKI_HOME_URL}", f"Wiki: {WIKI_HOME_URL}",
"", "",
SUBLINE, SUBLINE,
f"Dieser Newsletter wurde automatisch erstellt und ist nur für den internen", "Dieser Newsletter wurde automatisch erstellt und ist nur für den internen",
f"Gebrauch bei {ORG_NAME} bestimmt.", f"Gebrauch bei {ORG_NAME} bestimmt.",
LINE, LINE,
] ]
) )
def _html_section( # ---------------------------------------------------------------------------
heading: str, # Gemeinsame Helfer
items: list[dict[str, Any]], # ---------------------------------------------------------------------------
display: DisplayOptions,
empty_text: str,
kind: str = "edited",
) -> str:
accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT
accent_bg = COLOR_ACCENT_NEW_BG if kind == "new" else COLOR_ACCENT_EDIT_BG
badge = "NEU" if kind == "new" else "AKTUALISIERT"
blocks = [
f"""<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:28px;font-family:{FONT};">
<tr>
<td style="padding:0;font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{accent_bg};border:1px solid {COLOR_BORDER};font-family:{FONT};">
<tr>
<td style="width:6px;background:{accent};font-size:0;line-height:0;">&nbsp;</td>
<td style="padding:14px 16px;font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
<tr>
<td style="font-family:{FONT};">
<h2 style="margin:0;font-family:{FONT};font-size:17px;line-height:22px;font-weight:bold;color:{COLOR_HEADING};">{escape(heading)}</h2>
</td>
<td align="right" style="font-family:{FONT};white-space:nowrap;">
<span style="display:inline-block;padding:4px 10px;background:{accent};font-family:{FONT};font-size:11px;line-height:14px;font-weight:bold;color:{COLOR_WHITE};">{badge}</span>
<span style="display:inline-block;margin-left:8px;padding:4px 10px;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};font-family:{FONT};font-size:11px;line-height:14px;color:{COLOR_MUTED};">{len(items)}</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>"""
]
if not items:
blocks.append(
f'<p style="margin:14px 0 0;padding:0 4px;font-family:{FONT};font-size:14px;line-height:22px;color:{COLOR_MUTED};">{escape(empty_text)}</p>'
)
return "\n".join(blocks)
blocks.append(
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:10px;font-family:{FONT};">'
)
for idx, item in enumerate(items, start=1):
blocks.append(_html_item_row(idx, item, display, accent))
blocks.append("</table>")
return "\n".join(blocks)
def _html_meta_tags(item: dict[str, Any], display: DisplayOptions) -> str:
tags: list[str] = []
if display["show_date"]:
tags.append(_html_meta_tag("Datum", _format_ts(item.get("timestamp"))))
if display["show_user"]:
tags.append(_html_meta_tag("Benutzer", str(item.get("user", "-"))))
if display["show_category"]:
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
tags.append(_html_meta_tag("Kategorie", cat))
if not tags:
return ""
return f'<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin-top:10px;font-family:{FONT};"><tr>{"".join(tags)}</tr></table>'
def _html_meta_tag(label: str, value: str) -> str:
return f"""<td style="padding:0 8px 0 0;font-family:{FONT};">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
<tr>
<td style="padding:5px 10px;background:{COLOR_BG};border:1px solid {COLOR_BORDER};font-family:{FONT};font-size:11px;line-height:16px;color:{COLOR_MUTED};">
<strong style="font-family:{FONT};color:{COLOR_TEXT};">{escape(label)}:</strong> {escape(value)}
</td>
</tr>
</table>
</td>"""
def _html_item_row(idx: int, item: dict[str, Any], display: DisplayOptions, accent: str) -> str:
title = escape(item.get("title", "Ohne Titel"))
url = escape(_article_url(item.get("title", "")))
meta_html = _html_meta_tags(item, display)
return f"""<tr>
<td style="padding:0 0 10px;font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};font-family:{FONT};">
<tr>
<td style="width:4px;background:{accent};font-size:0;line-height:0;">&nbsp;</td>
<td style="padding:14px 16px;font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
<tr>
<td style="width:26px;vertical-align:top;font-family:{FONT};font-size:13px;line-height:20px;font-weight:bold;color:{accent};">{idx}.</td>
<td style="vertical-align:top;font-family:{FONT};">
<a href="{url}" style="font-family:{FONT};font-size:15px;line-height:22px;font-weight:bold;color:{COLOR_LINK};text-decoration:none;">{title}</a>
{meta_html}
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>"""
def _html_summary(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str:
total = len(new_articles) + len(edited_articles)
return f"""<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:32px;font-family:{FONT};">
<tr>
<td style="padding:0 0 10px;font-family:{FONT};font-size:14px;line-height:20px;font-weight:bold;color:{COLOR_HEADING};">Zusammenfassung</td>
</tr>
<tr>
<td style="font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
<tr>
<td width="33%" style="padding:18px 12px;background:{COLOR_ACCENT_NEW_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_ACCENT_NEW};">{len(new_articles)}</div>
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Neue Artikel</div>
</td>
<td width="4" style="font-size:0;line-height:0;">&nbsp;</td>
<td width="33%" style="padding:18px 12px;background:{COLOR_ACCENT_EDIT_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_ACCENT_EDIT};">{len(edited_articles)}</div>
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Bearbeitet</div>
</td>
<td width="4" style="font-size:0;line-height:0;">&nbsp;</td>
<td width="33%" style="padding:18px 12px;background:{COLOR_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_HEADING};">{total}</div>
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Gesamt</div>
</td>
</tr>
</table>
</td>
</tr>
</table>"""
def _article_url(title: str) -> str: def _article_url(title: str) -> str:
return f"{WIKI_ARTICLE_BASE}{quote(title.replace(' ', '_'))}" return f"{WIKI_ARTICLE_BASE}{quote(title.replace(' ', '_'))}"

View File

@@ -32,14 +32,16 @@ def send_newsletter(
text_content: str, text_content: str,
recipients: Sequence[str], recipients: Sequence[str],
scheduled: bool = False, scheduled: bool = False,
) -> None: ) -> tuple[str, str]:
settings = get_smtp_settings(db) settings = get_smtp_settings(db)
if settings["enabled"].lower() != "true": if settings["enabled"].lower() != "true":
_log(db, subject, recipients, "skipped", "SMTP ist deaktiviert.", scheduled) detail = "SMTP ist deaktiviert. Bitte in der SMTP-Konfiguration aktivieren."
return _log(db, subject, recipients, "skipped", detail, scheduled)
return "skipped", detail
if not settings["host"] or not settings["from_email"] or not recipients: if not settings["host"] or not settings["from_email"] or not recipients:
_log(db, subject, recipients, "failed", "SMTP unvollständig konfiguriert.", scheduled) detail = "SMTP unvollständig konfiguriert (Host, Absender oder Empfänger fehlen)."
return _log(db, subject, recipients, "failed", detail, scheduled)
return "failed", detail
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg.attach(MIMEText(text_content, "plain", "utf-8")) msg.attach(MIMEText(text_content, "plain", "utf-8"))
@@ -56,9 +58,13 @@ def send_newsletter(
if settings["username"]: if settings["username"]:
server.login(settings["username"], settings["password"]) server.login(settings["username"], settings["password"])
server.sendmail(settings["from_email"], list(recipients), msg.as_string()) 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: 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: 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: def __init__(self) -> None:
self.api_url = settings.wiki_api_url self.api_url = settings.wiki_api_url
async def get_recent_changes(self, days: int = 30, only_edited: bool = False) -> list[dict[str, Any]]: async def get_recent_changes(
now = datetime.now(timezone.utc) self,
start = now - timedelta(days=days) days: int = 30,
rcstart = now.strftime("%Y-%m-%dT%H:%M:%SZ") article_type: str = "all",
rcend = start.strftime("%Y-%m-%dT%H:%M:%SZ") 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] = { params: dict[str, str] = {
"action": "query", "action": "query",
@@ -35,7 +42,9 @@ class WikiService:
"rcend": rcend, "rcend": rcend,
"rcnamespace": "0", "rcnamespace": "0",
} }
if only_edited: if article_type == "new":
params["rctype"] = "new"
elif article_type == "edited":
params["rctype"] = "edit" params["rctype"] = "edit"
else: else:
params["rctype"] = "new|edit" params["rctype"] = "new|edit"

View File

@@ -487,13 +487,63 @@ tbody tr:hover { background: #FAFAFA; }
.copy-status.success { color: var(--tk-success); } .copy-status.success { color: var(--tk-success); }
.copy-status.error { color: var(--tk-error); } .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 { .newsletter-preview-frame {
width: 100%; width: 100%;
min-height: 640px; min-height: 640px;
border: 1px solid var(--tk-border); 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); border-radius: var(--radius);
background: var(--tk-gray-light); background: var(--tk-gray-light);
box-shadow: var(--shadow-md); }
.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 { .textarea-actions {

View File

@@ -181,6 +181,39 @@ function showCopyDialog(text, title) {
document.addEventListener("keydown", copyDialogKeyHandler); 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) { function copyViaIframe(html) {
const iframe = document.createElement("iframe"); const iframe = document.createElement("iframe");
iframe.setAttribute("aria-hidden", "true"); iframe.setAttribute("aria-hidden", "true");
@@ -247,8 +280,13 @@ async function copyForOutlook() {
return; return;
} }
if (copyHtmlWithFallback(html)) { if (copyFromPreviewFrame()) {
setCopyStatus("Newsletter kopiert. In Outlook: Strg+V, dann ggf. Rechtsklick → „Format beibehalten“."); 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; return;
} }
@@ -261,14 +299,14 @@ async function copyForOutlook() {
"text/plain": new Blob([plain], { type: "text/plain" }), "text/plain": new Blob([plain], { type: "text/plain" }),
}), }),
]); ]);
setCopyStatus("Newsletter kopiert. In Outlook: Strg+V, dann ggf. Rechtsklick → „Format beibehalten."); setCopyStatus("Newsletter kopiert in Outlook mit Strg+V einfügen.");
return; return;
} catch (err) { } catch (err) {
console.warn("Clipboard API HTML copy failed.", err); console.warn("Clipboard API HTML copy failed.", err);
} }
} }
setCopyStatus("Bitte „Im Browser öffnen“ nutzen und dort Strg+A → Strg+C.", true); setCopyStatus("Kopieren fehlgeschlagen. Bitte „Im Browser öffnen“ nutzen.", true);
} }
function copyHtmlSource() { function copyHtmlSource() {
@@ -332,17 +370,11 @@ function openOutlookInBrowser() {
popup.document.write(html); popup.document.write(html);
popup.document.close(); popup.document.close();
popup.document.title = "Wiki-Newsletter zum Kopieren"; popup.document.title = "Wiki-Newsletter zum Kopieren";
setCopyStatus("Newsletter im Browser geöffnet: Strg+A → Strg+C, dann in Outlook einfügen."); setCopyStatus("Im neuen Tab: Alles markieren (Strg+A)kopieren (Strg+C) → in Outlook einfügen.");
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const outlookSource = document.getElementById("newsletter-outlook-source"); loadPreviewFrame();
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;
}
const htmlDisplay = document.getElementById("newsletter-html-display"); const htmlDisplay = document.getElementById("newsletter-html-display");
htmlDisplay?.addEventListener("focus", () => selectTextarea(htmlDisplay)); htmlDisplay?.addEventListener("focus", () => selectTextarea(htmlDisplay));

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ title if title else "Wiki Newsletter Admin" }}{% endblock %} · Thomas-Krenn.AG</title> <title>{% block title %}{{ title if title else "Wiki Newsletter Admin" }}{% endblock %} · Thomas-Krenn.AG</title>
<link rel="stylesheet" href="/static/css/main.css?v=3"> <link rel="stylesheet" href="/static/css/main.css?v=4">
</head> </head>
<body class="{% block body_class %}{% endblock %}"> <body class="{% block body_class %}{% endblock %}">
<header class="topbar"> <header class="topbar">

View File

@@ -35,16 +35,27 @@
<form method="post" action="/dashboard" class="form-grid"> <form method="post" action="/dashboard" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-row"> <div class="form-row">
<label>Zeitraum (Tage) <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 }}"> <input type="number" name="days" min="1" max="90" value="{{ filters.days }}">
</label> </label>
</div>
<div class="form-row">
<label>Kategorie (optional) <label>Kategorie (optional)
<input type="text" name="category" value="{{ filters.category }}" placeholder="z. B. Linux"> <input type="text" name="category" value="{{ filters.category }}" placeholder="z. B. Linux">
</label> </label>
</div> </div>
<label class="checkbox"> <label>Artikel-Auswahl
<input type="checkbox" name="only_edited" value="true" {% if filters.only_edited %}checked{% endif %}> <select name="article_type">
Nur bearbeitete Artikel (keine neu erstellten) <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> </label>
<fieldset class="display-options"> <fieldset class="display-options">
<legend>Anzeige im Newsletter</legend> <legend>Anzeige im Newsletter</legend>
@@ -61,6 +72,14 @@
Kategorie anzeigen Kategorie anzeigen
</label> </label>
</fieldset> </fieldset>
<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 %} {% if wiki_error %}
<p class="error">{{ wiki_error }}</p> <p class="error">{{ wiki_error }}</p>
{% endif %} {% endif %}
@@ -79,20 +98,55 @@
<p class="card-desc">Formatierten Newsletter ansehen und für Outlook kopieren.</p> <p class="card-desc">Formatierten Newsletter ansehen und für Outlook kopieren.</p>
</div> </div>
</div> </div>
<p class="hint"><strong>Empfohlen:</strong> „Im Browser öffnen“ → <strong>Strg+A</strong>, <strong>Strg+C</strong> → in Outlook <strong>Strg+V</strong>.</p> <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"> <div class="copy-actions">
<button type="button" id="open-outlook-btn" class="btn-primary">Im Browser öffnen</button> <button type="button" id="copy-outlook-btn" class="btn-primary">Vorschau kopieren</button>
<button type="button" id="copy-outlook-btn" class="btn-secondary">Für Outlook 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="download-html-btn" class="btn-secondary">HTML herunterladen</button>
<button type="button" id="copy-html-source-btn" class="btn-outline">HTML-Quelltext</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> <span id="copy-status" class="copy-status" aria-live="polite"></span>
</div> </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> <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-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-outlook-source" class="hidden-source" hidden readonly>{{ outlook_html }}</textarea>
<textarea id="newsletter-plain-source" class="hidden-source" hidden readonly>{{ plain_text }}</textarea> <textarea id="newsletter-plain-source" class="hidden-source" hidden readonly>{{ plain_text }}</textarea>
{% if send_result %}
<div class="send-banner send-banner-{{ send_result.status }}">{{ send_result.message }}</div>
{% endif %}
{% 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>
{% if filters.article_type != 'edited' %}
<section class="card card-accent-gray"> <section class="card card-accent-gray">
<div class="card-header"> <div class="card-header">
<h3>Neue Artikel <span class="count-badge orange">{{ articles_new|length }}</span></h3> <h3>Neue Artikel <span class="count-badge orange">{{ articles_new|length }}</span></h3>
@@ -122,7 +176,9 @@
</table> </table>
</div> </div>
</section> </section>
{% endif %}
{% if filters.article_type != 'new' %}
<section class="card card-accent-gray"> <section class="card card-accent-gray">
<div class="card-header"> <div class="card-header">
<h3>Bearbeitete Artikel <span class="count-badge">{{ articles_edited|length }}</span></h3> <h3>Bearbeitete Artikel <span class="count-badge">{{ articles_edited|length }}</span></h3>
@@ -152,6 +208,7 @@
</table> </table>
</div> </div>
</section> </section>
{% endif %}
<details class="card-details"> <details class="card-details">
<summary>Weitere Ausgabeformate</summary> <summary>Weitere Ausgabeformate</summary>
@@ -170,25 +227,6 @@
</div> </div>
</details> </details>
{% if user.role in ["admin", "editor"] %}
<details class="card-details">
<summary>SMTP-Versand</summary>
<div class="details-body">
<p class="hint">Newsletter direkt per E-Mail versenden (aktuell gewählte Filter).</p>
<form method="post" action="/newsletter/send-now">
<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" class="btn-secondary">Newsletter jetzt senden</button>
</form>
</div>
</details>
{% endif %}
{% if user.role == "admin" %} {% if user.role == "admin" %}
<details class="card-details"> <details class="card-details">
<summary>SMTP-Konfiguration</summary> <summary>SMTP-Konfiguration</summary>
@@ -276,10 +314,10 @@
<div class="sidebar-card"> <div class="sidebar-card">
<h4>Outlook-Tipp</h4> <h4>Outlook-Tipp</h4>
<ul> <ul>
<li>„Im Browser öffnen“ nutzen</li> <li><strong>Vorschau kopieren</strong> übernimmt die Live-Vorschau</li>
<li>Alles markieren (Strg+A)</li> <li>In Outlook: <strong>Strg+V</strong></li>
<li>Kopieren (Strg+C)</li> <li>Rechtsklick → <strong>Format beibehalten</strong></li>
<li>In Outlook einfügen</li> <li>Bei Dunkelmodus: Outlook-Design auf „Weiß“ stellen</li>
</ul> </ul>
</div> </div>
{% if user.role == "admin" %} {% if user.role == "admin" %}
@@ -297,5 +335,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/newsletter.js?v=6"></script> <script src="/static/js/newsletter.js?v=8"></script>
{% endblock %} {% endblock %}

View File

@@ -13,6 +13,12 @@
<div class="card-header"> <div class="card-header">
<h3>Neuen Benutzer anlegen</h3> <h3>Neuen Benutzer anlegen</h3>
</div> </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"> <form method="post" action="/admin/users" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-row"> <div class="form-row">
@@ -25,7 +31,7 @@
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Passwort <label>Passwort
<input type="password" name="password" required minlength="10"> <input type="password" name="password" required minlength="10" placeholder="Mindestens 10 Zeichen">
</label> </label>
<label>Rolle <label>Rolle
<select name="role"> <select name="role">

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>