diff --git a/app/main.py b/app/main.py index 92d0456..c33356b 100644 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,7 @@ from app.services.newsletter import ( filter_articles, parse_display_options, parse_highlights, + resolve_period, split_articles_by_type, ) from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter @@ -72,7 +73,14 @@ async def add_security_headers(request: Request, call_next): response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "same-origin" - response.headers["Content-Security-Policy"] = "default-src 'self'; style-src 'self'; script-src 'self';" + # Die Newsletter-Vorschau läuft in einem srcdoc-iframe und erbt diese CSP. + # E-Mail-HTML benötigt zwingend Inline-Styles; das Logo liegt auf einem externen https-Host. + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "img-src 'self' https: data:; " + "style-src 'self' 'unsafe-inline'; " + "script-src 'self';" + ) if not cookie_token: response.set_cookie( CSRF_COOKIE_NAME, @@ -199,6 +207,63 @@ def logout(request: Request, csrf_token: str = Form(...)): return response +def _empty_filters() -> dict: + return { + "days": 30, + "period": "days", + "article_type": "all", + "category": "", + "display": DEFAULT_DISPLAY, + "editor_tip": "", + "highlights": "", + } + + +async def _generate_newsletter( + *, + period_mode: str, + days: int, + article_type: str, + category: str, + display, + editor_tip: str, + highlight_list: list[str], +) -> dict: + p = resolve_period(period_mode, days) + result = { + "period": p, + "wiki_error": None, + "articles": [], + "articles_new": [], + "articles_edited": [], + "plain_text": "", + "raw_html": "", + "outlook_html": "", + "subject": "", + } + try: + articles = await wiki_service.get_recent_changes( + days=p["days"], article_type=article_type, start=p["start"], end=p["end"] + ) + filtered = filter_articles(articles, category_filter=category or None) + new_articles, edited_articles = split_articles_by_type(filtered) + prange = (p["start_str"], p["end_str"]) + result.update( + { + "articles": filtered, + "articles_new": new_articles, + "articles_edited": edited_articles, + "subject": create_subject(p["days"], category, p["label"]), + "plain_text": create_plain_text(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type), + "raw_html": create_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type), + "outlook_html": create_outlook_html(filtered, p["days"], display, category, editor_tip, highlight_list, p["label"], prange, article_type), + } + ) + except WikiFetchError as exc: + result["wiki_error"] = exc.message + return result + + @app.get("/dashboard", response_class=HTMLResponse) async def dashboard(request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)): context = _base_context(request, current_user, db) @@ -209,15 +274,10 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use "raw_html": "", "outlook_html": "", "plain_text": "", - "filters": { - "days": 30, - "only_edited": False, - "category": "", - "display": DEFAULT_DISPLAY, - "editor_tip": "", - "highlights": "", - }, + "subject": "", + "filters": _empty_filters(), "wiki_error": None, + "send_result": None, }) logs = db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all() context["send_logs"] = logs @@ -228,8 +288,9 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use async def dashboard_generate( request: Request, csrf_token: str = Form(...), + period: str = Form("days"), days: int = Form(30), - only_edited: str | None = Form(None), + article_type: str = Form("all"), category: str = Form(""), show_date: str | None = Form(None), show_user: str | None = Form(None), @@ -241,57 +302,57 @@ async def dashboard_generate( ): validate_csrf(request, csrf_token) days = max(1, min(days, 90)) - only_edited_flag = only_edited == "true" + if article_type not in ("all", "new", "edited"): + article_type = "all" display = parse_display_options(show_date, show_user, show_category) highlight_list = parse_highlights(highlights) - wiki_error = None - filtered: list = [] - articles_new: list = [] - articles_edited: list = [] - plain_text = "" - raw_html = "" - outlook_html = "" - try: - articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag) - filtered = filter_articles(articles, category_filter=category or None) - articles_new, articles_edited = split_articles_by_type(filtered) - title = create_subject(days, category) - plain_text = create_plain_text(filtered, days, display, category, editor_tip, highlight_list) - raw_html = create_html(filtered, days, display, category, editor_tip, highlight_list) - outlook_html = create_outlook_html(filtered, days, display, category, editor_tip, highlight_list) - run_scheduled_send_if_due(db, title, raw_html, plain_text) - except WikiFetchError as exc: - wiki_error = exc.message + + gen = await _generate_newsletter( + period_mode=period, + days=days, + article_type=article_type, + category=category, + display=display, + editor_tip=editor_tip, + highlight_list=highlight_list, + ) + if not gen["wiki_error"]: + run_scheduled_send_if_due(db, gen["subject"], gen["raw_html"], gen["plain_text"]) + context = _base_context(request, current_user, db) context.update( { - "articles": filtered, - "articles_new": articles_new, - "articles_edited": articles_edited, - "raw_html": raw_html, - "outlook_html": outlook_html, - "plain_text": plain_text, + "articles": gen["articles"], + "articles_new": gen["articles_new"], + "articles_edited": gen["articles_edited"], + "raw_html": gen["raw_html"], + "outlook_html": gen["outlook_html"], + "plain_text": gen["plain_text"], + "subject": gen["subject"], "filters": { "days": days, - "only_edited": only_edited_flag, + "period": period, + "article_type": article_type, "category": category, "display": display, "editor_tip": editor_tip, "highlights": highlights, }, "send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(), - "wiki_error": wiki_error, + "wiki_error": gen["wiki_error"], + "send_result": None, } ) return _render(request, "dashboard.html", context) -@app.post("/newsletter/send-now") +@app.post("/newsletter/send-now", response_class=HTMLResponse) async def send_now( request: Request, csrf_token: str = Form(...), + period: str = Form("days"), days: int = Form(30), - only_edited: str | None = Form(None), + article_type: str = Form("all"), category: str = Form(""), show_date: str | None = Form(None), show_user: str | None = Form(None), @@ -303,20 +364,56 @@ async def send_now( ): validate_csrf(request, csrf_token) display = parse_display_options(show_date, show_user, show_category) - only_edited_flag = only_edited == "true" + if article_type not in ("all", "new", "edited"): + article_type = "all" highlight_list = parse_highlights(highlights) days = max(1, min(days, 90)) - try: - articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag) - except WikiFetchError as exc: - raise HTTPException(status_code=502, detail=exc.message) from exc - filtered = filter_articles(articles, category_filter=category or None) - title = create_subject(days, category) - plain_text = create_plain_text(filtered, days, display, category, editor_tip, highlight_list) - raw_html = create_html(filtered, days, display, category, editor_tip, highlight_list) - recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] - send_newsletter(db, title, raw_html, plain_text, recipients, scheduled=False) - return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) + + gen = await _generate_newsletter( + period_mode=period, + days=days, + article_type=article_type, + category=category, + display=display, + editor_tip=editor_tip, + highlight_list=highlight_list, + ) + + send_result: dict[str, str] + if gen["wiki_error"]: + send_result = {"status": "failed", "message": f"Versand abgebrochen – Wiki-Fehler: {gen['wiki_error']}"} + elif not gen["articles"]: + send_result = {"status": "failed", "message": "Versand abgebrochen – im Zeitraum wurden keine Artikel gefunden."} + else: + recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] + 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") diff --git a/app/services/newsletter.py b/app/services/newsletter.py index abcdb49..5bb9873 100644 --- a/app/services/newsletter.py +++ b/app/services/newsletter.py @@ -101,13 +101,59 @@ def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[st return new_articles, edited_articles -def create_subject(days: int, category: str = "") -> str: - base = f"Interner Wiki-Newsletter – {ORG_NAME} ({days} Tage)" +def create_subject(days: int, category: str = "", period_label: str | None = None) -> str: + label = period_label or f"{days} Tage" + base = f"Interner Wiki-Newsletter – {ORG_NAME} ({label})" if category.strip(): return f"{base} | Kategorie: {category.strip()}" return base +_MONTHS_DE = [ + "Januar", "Februar", "März", "April", "Mai", "Juni", + "Juli", "August", "September", "Oktober", "November", "Dezember", +] + + +def _month_label(dt: datetime) -> str: + return f"{_MONTHS_DE[dt.month - 1]} {dt.year}" + + +def resolve_period(mode: str, days: int) -> dict[str, Any]: + """Berechnet Start/Ende und Beschriftung für einen Zeitraum-Modus. + + - "last_month": voriger Kalendermonat (z. B. 01.06.–01.07.) + - sonst: die letzten N Tage + """ + now = datetime.now() + if mode == "last_month": + first_this_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + last_day_prev = first_this_month - timedelta(days=1) + start = last_day_prev.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + end = first_this_month + return { + "mode": "last_month", + "start": start, + "end": end, + "start_str": start.strftime("%d.%m.%Y"), + "end_str": end.strftime("%d.%m.%Y"), + "label": _month_label(start), + "days": max(1, (end - start).days), + } + days = max(1, min(days, 90)) + end = now + start = now - timedelta(days=days) + return { + "mode": "days", + "start": start, + "end": end, + "start_str": start.strftime("%d.%m.%Y"), + "end_str": end.strftime("%d.%m.%Y"), + "label": f"{days} Tage", + "days": days, + } + + def parse_highlights(raw: str | None) -> list[str]: """Zerlegt das Highlight-Eingabefeld (Titel pro Zeile) in eine Liste.""" if not raw: @@ -155,17 +201,23 @@ def create_plain_text( category: str = "", editor_tip: str = "", highlights: list[str] | None = None, + period_label: str | None = None, + period: tuple[str, str] | None = None, + article_type: str = "all", ) -> str: display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) - period_start, period_end = _period_range(days) + show_new = article_type in ("all", "new") + show_edited = article_type in ("all", "edited") + period_start, period_end = period if period else _period_range(days) + period_desc = period_label or f"{days} Tage" created_at = datetime.now().strftime("%d.%m.%Y %H:%M") lines = [ LINE, f"{ORG_NAME.upper()} – INTERNER WIKI-NEWSLETTER", LINE, - f"Zeitraum: {period_start} bis {period_end} ({days} Tage)", + f"Zeitraum: {period_start} bis {period_end} ({period_desc})", f"Erstellt am: {created_at}", ] if category.strip(): @@ -202,17 +254,19 @@ def create_plain_text( lines.append(_plain_footer(new_articles, edited_articles)) return "\n".join(lines).strip() - lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum.")) - lines.append("") - lines.extend( - _plain_section( - "BEARBEITETE ARTIKEL", - edited_articles, - display, - empty_text="Keine bearbeiteten Artikel im Zeitraum.", + if show_new: + lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum.")) + lines.append("") + if show_edited: + lines.extend( + _plain_section( + "BEARBEITETE ARTIKEL", + edited_articles, + display, + empty_text="Keine bearbeiteten Artikel im Zeitraum.", + ) ) - ) - lines.append("") + lines.append("") lines.extend(_plain_editor_tip(editor_tip)) lines.append(_plain_footer(new_articles, edited_articles)) return "\n".join(lines).strip() @@ -239,12 +293,17 @@ def create_html( category: str = "", editor_tip: str = "", highlights: list[str] | None = None, + period_label: str | None = None, + period: tuple[str, str] | None = None, + article_type: str = "all", ) -> str: display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) - period_start, period_end = _period_range(days) + show_new = article_type in ("all", "new") + show_edited = article_type in ("all", "edited") + period_start, period_end = period if period else _period_range(days) total = len(new_articles) + len(edited_articles) - subject = escape(create_subject(days, category)) + subject = escape(create_subject(days, category, period_label)) preheader = escape(_summary_sentence(total)) period_label = escape(f"{period_start} – {period_end}") summary_text = escape(_summary_sentence(total)) @@ -255,12 +314,12 @@ def create_html( if not new_articles and not edited_articles: body = _html_empty_card("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.") else: - body = "\n".join( - [ - _html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"), - _html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited"), - ] - ) + sections = [] + if show_new: + sections.append(_html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new")) + if show_edited: + sections.append(_html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited")) + body = "\n".join(sections) summary_section = ( f'