20 Commits

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

View File

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

View File

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

View File

@@ -7,10 +7,16 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au
- Login und Benutzerverwaltung mit RBAC (`admin`, `editor`, `reader`) - 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,69 @@ 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`).
## Admin-Login & Passwort zurücksetzen
- Das Login erfolgt über **E-Mail + Passwort** (kein separater Benutzername). Beim ersten Start wird der Admin aus `ADMIN_BOOTSTRAP_EMAIL` / `ADMIN_BOOTSTRAP_PASSWORD` angelegt.
- **Wichtig:** Existiert der Admin bereits (persistente DB im Volume `newsletter_data`), wird eine spätere Passwort-Änderung in der `.env` normalerweise **nicht** übernommen.
- Um Passwort/Rolle/Status auf die `.env`-Werte zurückzusetzen: `ADMIN_BOOTSTRAP_RESET=true` setzen und Container neu starten:
```bash
docker compose up -d --force-recreate
```
Nach erfolgreichem Login `ADMIN_BOOTSTRAP_RESET=false` setzen, damit UI-Passwortänderungen nicht bei jedem Neustart überschrieben werden.
## Sicherheits-Hinweise für Produktion ## 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

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

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
@@ -77,6 +97,12 @@ def _bootstrap_admin() -> None:
try: try:
existing = db.query(User).filter(User.email == settings.admin_bootstrap_email).first() existing = db.query(User).filter(User.email == settings.admin_bootstrap_email).first()
if existing: if existing:
if settings.admin_bootstrap_reset:
existing.password_hash = hash_password(settings.admin_bootstrap_password)
existing.is_admin = True
existing.role = "admin"
existing.is_active = True
db.commit()
return return
user = User( user = User(
email=settings.admin_bootstrap_email, email=settings.admin_bootstrap_email,
@@ -99,7 +125,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 +164,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 +185,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 +195,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 +213,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 +280,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 +294,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,
recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] category=category,
send_newsletter(db, title, raw_html, plain_text, recipients, scheduled=False) display=display,
return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) 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()]
status_code, detail = send_newsletter(db, gen["subject"], gen["raw_html"], gen["plain_text"], recipients, scheduled=False)
send_result = {"status": status_code, "message": detail}
context = _base_context(request, current_user, db)
context.update(
{
"articles": gen["articles"],
"articles_new": gen["articles_new"],
"articles_edited": gen["articles_edited"],
"raw_html": gen["raw_html"],
"outlook_html": gen["outlook_html"],
"plain_text": gen["plain_text"],
"subject": gen["subject"],
"filters": {
"days": days,
"period": period,
"article_type": article_type,
"category": category,
"display": display,
"editor_tip": editor_tip,
"highlights": highlights,
},
"send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(),
"wiki_error": gen["wiki_error"],
"send_result": send_result,
}
)
return _render(request, "dashboard.html", context)
@app.post("/admin/smtp") @app.post("/admin/smtp")
@@ -325,14 +451,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 +496,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),
): ):
validate_csrf(request, csrf_token) try:
payload = UserCreate(email=email, full_name=full_name, password=password, role=role) validate_csrf(request, csrf_token)
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 +532,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):

File diff suppressed because it is too large Load Diff

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

@@ -11,7 +11,7 @@
<form method="post" action="/login" class="form-grid"> <form method="post" action="/login" class="form-grid">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>E-Mail <label>E-Mail
<input type="email" name="email" required autocomplete="username" placeholder="name@firma.de"> <input type="email" name="email" required autocomplete="username">
</label> </label>
<label>Passwort <label>Passwort
<input type="password" name="password" required autocomplete="current-password"> <input type="password" name="password" required autocomplete="current-password">

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>