22 Commits

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

View File

@@ -13,3 +13,8 @@ COOKIE_SECURE=false
# Auf true setzen, wenn hinter HTTPS/TLS-Terminierung (Reverse Proxy) # 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,
@@ -93,10 +119,15 @@ def _bootstrap_admin() -> None:
def _base_context(request: Request, user: User, db: Session) -> dict: def _base_context(request: Request, user: User, db: Session) -> dict:
path = request.url.path
active_nav = "dashboard"
if path.startswith("/admin/users"):
active_nav = "users"
return { 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,
} }
@@ -133,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,
}, },
) )
@@ -154,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.",
}, },
) )
@@ -164,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.",
}, },
) )
@@ -182,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)
@@ -192,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
@@ -209,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")
@@ -320,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(...),
@@ -338,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,
@@ -353,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

129
preview_send.html Normal file
View File

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