Files
TK-Wiki-Newsletter/app/services/newsletter.py
smueller d2df1d2bed Enhance newsletter footer with creator attribution
- Updated the internal newsletter HTML templates to include a creator attribution in the footer.
- Added "Made with ❤️ by Samuel" to both the HTML and Outlook formats for a personalized touch.
2026-07-07 16:12:26 +02:00

1036 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import datetime, timedelta
from html import escape
from typing import Any, TypedDict
from urllib.parse import quote
WIKI_ARTICLE_BASE = "https://www.thomas-krenn.com/de/wiki/"
WIKI_HOME_URL = "https://www.thomas-krenn.com/de/wiki/Hauptseite"
ORG_NAME = "Thomas-Krenn.AG"
TK_LOGO_URL = "https://www.thomas-krenn.com/res/pics/tk_logo_340px.png"
LINE = "=" * 78
SUBLINE = "-" * 78
FONT = "Arial, Helvetica, sans-serif"
# Thomas-Krenn.AG Corporate Design (Orange + Anthrazit)
COLOR_TK_ORANGE = "#ED6B06"
COLOR_TK_ORANGE_DARK = "#C95605"
COLOR_TK_ORANGE_LIGHT = "#FFF4ED"
COLOR_TK_ORANGE_BORDER = "#F5C9A6"
COLOR_TK_GRAY_DARK = "#4A4A4A"
COLOR_TK_GRAY = "#6B6B6B"
COLOR_TK_GRAY_LIGHT = "#F5F5F5"
COLOR_TEXT = "#1A1A1A"
COLOR_MUTED = COLOR_TK_GRAY
COLOR_HEADING = COLOR_TK_GRAY_DARK
COLOR_LINK = COLOR_TK_ORANGE
COLOR_BORDER = "#E0E0E0"
COLOR_BG = COLOR_TK_GRAY_LIGHT
COLOR_PAGE_BG = "#ECEEF1"
COLOR_WHITE = "#FFFFFF"
COLOR_ACCENT_NEW = COLOR_TK_ORANGE
COLOR_ACCENT_NEW_BG = COLOR_TK_ORANGE_LIGHT
COLOR_ACCENT_EDIT = COLOR_TK_GRAY_DARK
COLOR_ACCENT_EDIT_BG = "#EEEEEE"
class DisplayOptions(TypedDict):
show_date: bool
show_user: bool
show_category: bool
DEFAULT_DISPLAY: DisplayOptions = {
"show_date": True,
"show_user": True,
"show_category": True,
}
def parse_display_options(
show_date: str | None = None,
show_user: str | None = None,
show_category: str | None = None,
) -> DisplayOptions:
return {
"show_date": show_date == "true",
"show_user": show_user == "true",
"show_category": show_category == "true",
}
IGNORED_ARTICLE_TITLES = frozenset({"Hauptseite"})
def filter_articles(
articles: list[dict[str, Any]],
category_filter: str | None = None,
) -> list[dict[str, Any]]:
articles = [a for a in articles if a.get("title") not in IGNORED_ARTICLE_TITLES]
if not category_filter:
return articles
wanted = category_filter.strip().lower()
return [
a
for a in articles
if any(c.lower() == wanted for c in a.get("categories", []))
]
def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
sorted_articles = sorted(articles, key=lambda a: a.get("timestamp", ""), reverse=True)
new_articles: list[dict[str, Any]] = []
edited_articles: list[dict[str, Any]] = []
new_titles: set[str] = set()
for item in sorted_articles:
title = item.get("title", "")
if not title or item.get("type") != "new":
continue
if title not in new_titles:
new_articles.append(item)
new_titles.add(title)
seen_edited: set[str] = set()
for item in sorted_articles:
title = item.get("title", "")
if not title or title in new_titles or title in seen_edited:
continue
edited_articles.append(item)
seen_edited.add(title)
return new_articles, edited_articles
def create_subject(month_label: str | None = None, days: int = 30) -> str:
label = month_label or f"{days} Tage"
return f"Neue Wiki-Artikel Veröffentlichungen vom {label} | Wiki-Redaktion"
_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),
"month_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",
"month_label": _month_label(end),
"days": days,
}
def parse_highlights(raw: str | None) -> list[str]:
"""Zerlegt das Highlight-Eingabefeld (Titel pro Zeile) in eine Liste."""
if not raw:
return []
return [line.strip() for line in raw.splitlines() if line.strip()]
def _match_highlights(
articles: list[dict[str, Any]],
highlights: list[str] | None,
) -> list[dict[str, Any]]:
"""Ordnet Highlight-Titel den geladenen Artikeln zu (max. 3)."""
if not highlights:
return []
result: list[dict[str, Any]] = []
for wanted in highlights:
key = wanted.strip().lower()
if not key:
continue
for article in articles:
if article.get("title", "").strip().lower() == key:
result.append(article)
break
if len(result) >= 3:
break
return result
def _summary_sentence(total: int) -> str:
if total == 0:
return "Aktuell wurden keine neuen Wiki-Artikel gefunden."
if total == 1:
return "Es gibt einen neuen bzw. aktualisierten Wiki-Artikel."
return f"Es gibt {total} neue bzw. aktualisierte Wiki-Artikel."
# ---------------------------------------------------------------------------
# Plain-Text
# ---------------------------------------------------------------------------
def create_plain_text(
articles: list[dict[str, Any]],
days: int,
display: DisplayOptions | None = None,
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",
contact_email: str = "",
) -> str:
display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles)
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} ({period_desc})",
f"Erstellt am: {created_at}",
]
if category.strip():
lines.append(f"Kategorie: {category.strip()}")
lines.extend(
[
"",
"Liebe Kolleginnen und Kollegen,",
"",
_intro_sentence(period_start, period_end),
"",
]
)
lines.extend(_plain_contact_line(contact_email))
highlight_articles = _match_highlights(new_articles + edited_articles, highlights)
if highlight_articles:
lines.extend([SUBLINE, "TOP HIGHLIGHTS", SUBLINE, ""])
for idx, item in enumerate(highlight_articles, start=1):
lines.append(f" {idx}. {item.get('title', 'Ohne Titel')}")
lines.append(f" {_article_url(item.get('title', ''))}")
lines.append("")
if not new_articles and not edited_articles:
lines.extend(
[
SUBLINE,
"HINWEIS",
SUBLINE,
"Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.",
"",
]
)
lines.extend(_plain_editor_tip(editor_tip))
lines.append(_plain_footer(new_articles, edited_articles))
return "\n".join(lines).strip()
if show_new:
lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum."))
lines.append("")
if show_edited:
lines.extend(
_plain_section(
"BEARBEITETE ARTIKEL",
edited_articles,
display,
empty_text="Keine bearbeiteten Artikel im Zeitraum.",
)
)
lines.append("")
lines.extend(_plain_editor_tip(editor_tip))
lines.append(_plain_footer(new_articles, edited_articles))
return "\n".join(lines).strip()
def _plain_contact_line(contact_email: str) -> list[str]:
if contact_email.strip():
return [
f"Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team ({contact_email.strip()}).",
"",
]
return ["Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.", ""]
def _plain_editor_tip(editor_tip: str) -> list[str]:
text = (editor_tip or "").strip()
if not text:
return []
lines = [SUBLINE, "REDAKTIONSNOTIZ", SUBLINE, ""]
lines.extend(f" {line}" for line in text.splitlines())
lines.append("")
return lines
# ---------------------------------------------------------------------------
# HTML (Standard, für SMTP-Versand & Quelltext)
# ---------------------------------------------------------------------------
def create_html(
articles: list[dict[str, Any]],
days: int,
display: DisplayOptions | None = None,
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",
month_label: str | None = None,
contact_email: str = "",
) -> str:
display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles)
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(month_label or period_label, days))
preheader = escape(_summary_sentence(total))
period_label = escape(f"{period_start} {period_end}")
summary_text = escape(_summary_sentence(total))
highlight_articles = _match_highlights(new_articles + edited_articles, highlights)
highlights_section = _html_highlights(highlight_articles)
if not new_articles and not edited_articles:
body = _html_empty_card("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.")
else:
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'<tr><td style="padding:24px 32px 0 32px;" class="mobile-padding">{_html_summary(new_articles, edited_articles)}</td></tr>'
)
editor_tip_section = _html_editor_tip(editor_tip)
category_hint = ""
if category.strip():
category_hint = (
f'<tr><td style="padding:0 32px 4px 32px;font-family:{FONT};" class="mobile-padding">'
f'<p style="margin:0;font-family:{FONT};font-size:13px;line-height:20px;color:{COLOR_MUTED};">'
f'<strong style="color:{COLOR_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}</p></td></tr>'
)
if contact_email.strip():
ce = escape(contact_email.strip())
contact_html = (
f'Fragen oder ein Themenwunsch? '
f'<a href="mailto:{ce}" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">Meldet euch gern beim Wiki-Team</a>.'
)
else:
contact_html = "Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team."
contact_section = (
f'<tr><td style="padding:16px 32px 24px 32px;" class="mobile-padding">'
f'<p style="margin:8px 0 0;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">{contact_html}</p>'
f"</td></tr>"
)
return f"""<!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>{subject}</title>
<style type="text/css">
html, body {{ margin:0 !important; padding:0 !important; width:100% !important; background:{COLOR_PAGE_BG}; }}
table, td {{ border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; }}
img {{ border:0; outline:none; text-decoration:none; -ms-interpolation-mode:bicubic; }}
a {{ text-decoration:none; }}
.preheader {{ display:none !important; visibility:hidden; opacity:0; color:transparent; height:0; width:0; max-height:0; max-width:0; overflow:hidden; mso-hide:all; }}
@media only screen and (max-width: 700px) {{
.email-container {{ width:100% !important; }}
.mobile-padding {{ padding-left:16px !important; padding-right:16px !important; }}
.mobile-headline {{ font-size:28px !important; line-height:34px !important; }}
.mobile-card-title {{ font-size:18px !important; line-height:25px !important; }}
}}
</style>
<!--[if mso]>
<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:{COLOR_PAGE_BG};font-family:{FONT};color:{COLOR_TEXT};">
<div class="preheader">{preheader}&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;</div>
<center role="article" aria-roledescription="email" lang="de" style="width:100%;background:{COLOR_PAGE_BG};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{COLOR_PAGE_BG}" style="width:100%;background:{COLOR_PAGE_BG};">
<tr>
<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:{COLOR_WHITE};border-top:4px solid {COLOR_TK_ORANGE};">
<tr>
<td align="center" style="padding:24px 32px 18px 32px;background:{COLOR_TK_GRAY_DARK};" class="mobile-padding">
<img src="{TK_LOGO_URL}" width="190" alt="Thomas-Krenn Logo" style="display:block;margin:0 auto;border:0;outline:none;text-decoration:none;height:auto;">
</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:{COLOR_TK_GRAY_DARK};">
<tr>
<td style="padding:28px 26px;font-family:{FONT};">
<div style="font-size:12px;line-height:16px;font-weight:bold;color:{COLOR_TK_ORANGE};letter-spacing:1px;text-transform:uppercase;">Thomas-Krenn Wiki Update</div>
<div class="mobile-headline" style="font-size:32px;line-height:38px;font-weight:bold;color:{COLOR_WHITE};margin-top:10px;">Die neuesten Wiki-Artikel</div>
<div style="font-size:16px;line-height:25px;color:#DDDDDD;margin-top:10px;">{period_label} &nbsp;|&nbsp; {summary_text}</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:22px 32px 6px 32px;" class="mobile-padding">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{COLOR_TK_ORANGE_LIGHT};border-left:4px solid {COLOR_TK_ORANGE};">
<tr>
<td style="padding:18px 20px;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">
<p style="margin:0 0 10px;font-family:{FONT};">Servus,</p>
<p style="margin:0;font-family:{FONT};">hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {escape(period_start)} bis {escape(period_end)}.</p>
</td>
</tr>
</table>
</td>
</tr>
{contact_section}
{category_hint}
{highlights_section}
{body}
{summary_section}
{editor_tip_section}
<tr>
<td align="center" style="padding:28px 32px 4px 32px;" class="mobile-padding">
{_cta_button(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
</td>
</tr>
<tr>
<td style="padding:24px 32px 28px 32px;" class="mobile-padding">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr>
<td style="font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">
Viele Grüße<br>
Euer Thomas-Krenn-Wiki Team
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="background:{COLOR_TK_GRAY_DARK};padding:22px 20px;text-align:center;font-family:{FONT};">
<div style="font-size:12px;line-height:19px;color:#EEF0F2;">
Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung<br>
Tel.: +49 8551 9150 0 | Fax: +49 8551 9150 55 |
<a href="{WIKI_HOME_URL}" target="_blank" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">thomas-krenn.com</a><br>
<span style="color:#AEB2B7;">Automatisch erstellter interner Newsletter &middot; Nur für den internen Gebrauch &middot; Made with ❤️ by Samuel</span>
</div>
</td>
</tr>
</table>
</td>
</tr>
</table>
</center>
</body>
</html>"""
def _cta_button(url: str, label: str) -> str:
return (
f'<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;">'
f'<tr><td bgcolor="{COLOR_TK_ORANGE}" style="background:{COLOR_TK_ORANGE};border-radius:6px;">'
f'<a href="{escape(url)}" target="_blank" style="display:inline-block;padding:12px 22px;font-family:{FONT};'
f'font-size:14px;line-height:18px;font-weight:bold;color:{COLOR_WHITE};text-decoration:none;">{escape(label)}</a>'
f'</td></tr></table>'
)
def _html_empty_card(text: str) -> str:
return (
f'<tr><td style="padding:8px 32px 8px 32px;" class="mobile-padding">'
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};border-radius:12px;">'
f'<tr><td style="padding:20px 22px;font-family:{FONT};font-size:15px;line-height:23px;color:{COLOR_TEXT};">'
f'{escape(text)}</td></tr></table></td></tr>'
)
def _html_highlights(items: list[dict[str, Any]]) -> str:
if not items:
return ""
rows = ""
for idx, item in enumerate(items[:3], start=1):
url = escape(_article_url(item.get("title", "")))
title = escape(item.get("title", "Ohne Titel"))
rows += (
f'<tr><td style="padding:0 0 8px 0;font-family:{FONT};font-size:15px;line-height:22px;color:{COLOR_TEXT};">'
f'<span style="display:inline-block;min-width:22px;font-weight:bold;color:{COLOR_TK_ORANGE};">{idx}.</span> '
f'<a href="{url}" target="_blank" style="color:{COLOR_TEXT};text-decoration:none;">{title}</a></td></tr>'
)
return (
f'<tr><td style="padding:8px 32px 4px 32px;" class="mobile-padding">'
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;background:{COLOR_TK_ORANGE_LIGHT};border:1px solid {COLOR_TK_ORANGE_BORDER};border-radius:12px;">'
f'<tr><td style="padding:16px 18px 8px 18px;font-family:{FONT};font-size:11px;line-height:15px;'
f'color:{COLOR_TK_ORANGE_DARK};font-weight:bold;letter-spacing:.8px;text-transform:uppercase;">Top Highlights</td></tr>'
f'<tr><td style="padding:0 18px 12px 18px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">{rows}</table></td></tr>'
f'</table></td></tr>'
)
def _html_editor_tip(editor_tip: str) -> str:
text = (editor_tip or "").strip()
if not text:
return ""
body = escape(text).replace("\n", "<br>")
return (
f'<tr><td style="padding:16px 32px 4px 32px;" class="mobile-padding">'
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;background:{COLOR_TK_GRAY_LIGHT};border:1px solid {COLOR_BORDER};border-radius:12px;">'
f'<tr><td style="padding:16px 18px 8px 18px;font-family:{FONT};font-size:11px;line-height:15px;'
f'color:{COLOR_HEADING};font-weight:bold;letter-spacing:.8px;text-transform:uppercase;">Redaktionsnotiz</td></tr>'
f'<tr><td style="padding:0 18px 16px 18px;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">{body}</td></tr>'
f'</table></td></tr>'
)
def _html_section(
heading: str,
items: list[dict[str, Any]],
display: DisplayOptions,
empty_text: str,
kind: str = "edited",
) -> str:
accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT
heading_html = (
f'<tr><td style="padding:26px 32px 8px 32px;font-family:{FONT};" class="mobile-padding">'
f'<div style="font-family:{FONT};font-size:18px;line-height:24px;font-weight:bold;color:{COLOR_HEADING};'
f'border-bottom:2px solid {accent};padding-bottom:8px;">{escape(heading)} '
f'<span style="color:{accent};">({len(items)})</span></div></td></tr>'
)
if not items:
return heading_html + (
f'<tr><td style="padding:8px 32px 4px 32px;font-family:{FONT};font-size:14px;line-height:22px;'
f'color:{COLOR_MUTED};" class="mobile-padding">{escape(empty_text)}</td></tr>'
)
cards = "".join(_html_article_card(idx, item, display, kind) for idx, item in enumerate(items, start=1))
return heading_html + cards
def _html_article_card(idx: int, item: dict[str, Any], display: DisplayOptions, kind: str) -> str:
accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT
eyebrow = "Neuer Beitrag" if kind == "new" else "Aktualisierter Beitrag"
title = escape(item.get("title", "Ohne Titel"))
url = escape(_article_url(item.get("title", "")))
meta_bits: list[str] = []
if display["show_date"]:
meta_bits.append(f"am {_format_ts(item.get('timestamp'))}")
if display["show_user"]:
meta_bits.append(f"von {escape(str(item.get('user', '-')))}")
meta_html = ""
if meta_bits:
meta_html = (
f'<tr><td style="padding:0 22px 12px 22px;font-family:{FONT};font-size:13px;line-height:20px;'
f'color:{COLOR_MUTED};">Veröffentlicht {" ".join(meta_bits)}.</td></tr>'
)
cat_html = ""
if display["show_category"]:
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
cat_html = (
f'<tr><td style="padding:0 22px 12px 22px;font-family:{FONT};font-size:12px;line-height:18px;'
f'color:{COLOR_MUTED};">Kategorie: {escape(cat)}</td></tr>'
)
return (
f'<tr><td style="padding:0 32px 16px 32px;" class="mobile-padding">'
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
f'style="width:100%;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};border-left:4px solid {accent};border-radius:12px;">'
f'<tr><td style="padding:16px 22px 4px 22px;font-family:{FONT};font-size:11px;line-height:16px;font-weight:bold;'
f'letter-spacing:.7px;text-transform:uppercase;color:{accent};">{idx}. {eyebrow}</td></tr>'
f'<tr><td class="mobile-card-title" style="padding:0 22px 8px 22px;font-family:{FONT};font-size:20px;'
f'line-height:27px;font-weight:bold;color:{COLOR_HEADING};">'
f'<a href="{url}" target="_blank" style="color:{COLOR_HEADING};text-decoration:none;">{title}</a></td></tr>'
f'{meta_html}{cat_html}'
f'<tr><td style="padding:2px 22px 18px 22px;">{_cta_button(url, "Zum Beitrag →")}</td></tr>'
f'</table></td></tr>'
)
def _html_summary(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str:
total = len(new_articles) + len(edited_articles)
return f"""<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:4px;font-family:{FONT};">
<tr>
<td style="padding:0 0 10px;font-family:{FONT};font-size:14px;line-height:20px;font-weight:bold;color:{COLOR_HEADING};">Zusammenfassung</td>
</tr>
<tr>
<td style="font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
<tr>
<td width="33%" style="padding:18px 12px;background:{COLOR_ACCENT_NEW_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_ACCENT_NEW};">{len(new_articles)}</div>
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Neue Artikel</div>
</td>
<td width="4" style="font-size:0;line-height:0;">&nbsp;</td>
<td width="33%" style="padding:18px 12px;background:{COLOR_ACCENT_EDIT_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_ACCENT_EDIT};">{len(edited_articles)}</div>
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Bearbeitet</div>
</td>
<td width="4" style="font-size:0;line-height:0;">&nbsp;</td>
<td width="33%" style="padding:18px 12px;background:{COLOR_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_HEADING};">{total}</div>
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Gesamt</div>
</td>
</tr>
</table>
</td>
</tr>
</table>"""
# ---------------------------------------------------------------------------
# Outlook/Word-kompatibles HTML (UI-Vorschau & Copy-Paste in Outlook)
# ---------------------------------------------------------------------------
OUTLOOK_FONT = "Arial, sans-serif"
OUTLOOK_TEXT = COLOR_TEXT
OUTLOOK_MUTED = COLOR_TK_GRAY
OUTLOOK_LINK = COLOR_TK_ORANGE
OUTLOOK_HEADING = COLOR_TK_GRAY_DARK
OUTLOOK_BORDER = COLOR_BORDER
OUTLOOK_WHITE = COLOR_WHITE
OUTLOOK_ORANGE = COLOR_TK_ORANGE
OUTLOOK_GRAY_DARK = COLOR_TK_GRAY_DARK
def _outlook_td_attrs(
bg: str = OUTLOOK_WHITE,
*,
colspan: int | None = None,
width: str | None = None,
valign: str | None = None,
extra_style: str = "",
) -> str:
"""Tabellen-Zelle mit Outlook-Dunkelmodus-Schutz (data-ogsb/data-ogsc)."""
parts = [
f'bgcolor="{bg}"',
f'style="background-color:{bg} !important;color:{OUTLOOK_TEXT} !important;{extra_style}"',
f'data-ogsb="{bg}"',
]
if colspan:
parts.insert(0, f'colspan="{colspan}"')
if width:
parts.append(f'width="{width}"')
if valign:
parts.append(f'valign="{valign}"')
return " ".join(parts)
def _outlook_font(
text: str,
*,
color: str = OUTLOOK_TEXT,
size: str = "2",
bold: bool = False,
) -> str:
inner = escape(text)
if bold:
inner = f"<b>{inner}</b>"
return (
f'<span style="color:{color} !important;" data-ogsc="{color}">'
f'<font face="Arial" color="{color}" size="{size}">{inner}</font>'
f"</span>"
)
def _outlook_link(title: str, url: str, *, bold: bool = True, color: str = OUTLOOK_LINK, size: str = "2") -> str:
weight = "<b>" if bold else ""
weight_end = "</b>" if bold else ""
return (
f'<a href="{escape(url)}" '
f'style="color:{color} !important;text-decoration:none !important;mso-style-priority:100 !important;">'
f'<span style="color:{color} !important;" data-ogsc="{color}">'
f'<font face="Arial" color="{color}" size="{size}">'
f"{weight}{escape(title)}{weight_end}"
f"</font></span></a>"
)
def _outlook_cta(url: str, label: str) -> str:
return (
f'<table cellpadding="0" cellspacing="0" border="0"><tr>'
f'<td bgcolor="{OUTLOOK_ORANGE}" style="background-color:{OUTLOOK_ORANGE} !important;padding:9px 16px;" data-ogsb="{OUTLOOK_ORANGE}">'
f'{_outlook_link(label, url, color="#FFFFFF")}'
f'</td></tr></table>'
)
def create_outlook_html(
articles: list[dict[str, Any]],
days: int,
display: DisplayOptions | None = None,
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",
month_label: str | None = None,
contact_email: str = "",
) -> str:
"""Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook)."""
display = display or DEFAULT_DISPLAY
new_articles, edited_articles = split_articles_by_type(articles)
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(month_label or period_label, days))
period_label = f"{period_start} {period_end}"
summary_text = _summary_sentence(total)
highlight_articles = _match_highlights(new_articles + edited_articles, highlights)
highlights_rows = _outlook_highlights(highlight_articles)
if not new_articles and not edited_articles:
body_rows = f"""<tr>
<td {_outlook_td_attrs(extra_style="padding:12px 0;")}>
{_outlook_font("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)}
</td>
</tr>"""
else:
sections = []
if show_new:
sections.append(_outlook_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"))
if show_edited:
sections.append(_outlook_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited"))
body_rows = "\n".join(sections)
category_block = ""
if category.strip():
category_block = (
f'<p style="margin:0 0 10px;">'
f'{_outlook_font("Filter: ", color=OUTLOOK_HEADING, bold=True)}'
f'{_outlook_font(f"Kategorie {category.strip()}", color=OUTLOOK_MUTED, size="1")}'
f"</p>"
)
summary_row = f"""<tr>
<td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
{_outlook_font("Zusammenfassung: ", color=OUTLOOK_HEADING, bold=True, size="1")}
{_outlook_font(f"{len(new_articles)} neu · {len(edited_articles)} bearbeitet · {total} gesamt", color=OUTLOOK_MUTED, size="1")}
</td>
</tr>"""
editor_tip_row = _outlook_editor_tip(editor_tip)
if contact_email.strip():
contact_block = (
f'<p style="margin:12px 0 20px;">'
f'{_outlook_font("Fragen oder ein Themenwunsch? ", size="1")}'
f'{_outlook_link("Meldet euch gern beim Wiki-Team", f"mailto:{contact_email.strip()}", color=OUTLOOK_ORANGE, bold=False, size="1")}'
f'{_outlook_font(".", size="1")}'
f"</p>"
)
else:
contact_block = (
f'<p style="margin:12px 0 20px;">'
f'{_outlook_font("Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.", size="1")}'
f"</p>"
)
return f"""<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">
<head>
<meta charset="utf-8">
<meta name="ProgId" content="Word.Document">
<meta name="Generator" content="Microsoft Word 15">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>{subject}</title>
<!--[if mso]>
<style type="text/css">
body, table, td, p, font, span, strong {{ font-family: Arial, sans-serif !important; }}
a, span.MsoHyperlink, span.MsoHyperlinkFollowed {{
color: {OUTLOOK_LINK} !important;
text-decoration: none !important;
}}
</style>
<![endif]-->
<style type="text/css">
:root {{ color-scheme: light dark; supported-color-schemes: light dark; }}
body {{ background-color: {OUTLOOK_WHITE} !important; color: {OUTLOOK_TEXT} !important; }}
a, a:link, a:visited, a:hover, a:active {{
color: {OUTLOOK_LINK} !important;
text-decoration: none !important;
}}
</style>
</head>
<body bgcolor="{OUTLOOK_WHITE}" style="margin:0;padding:16px;background-color:{OUTLOOK_WHITE} !important;color:{OUTLOOK_TEXT} !important;" data-ogsb="{OUTLOOK_WHITE}">
<table width="640" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="{OUTLOOK_WHITE}" style="width:640px;background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}">
<tr>
<td {_outlook_td_attrs(bg=OUTLOOK_ORANGE, extra_style="height:5px;font-size:0;line-height:0;")}>&nbsp;</td>
</tr>
<tr>
<td {_outlook_td_attrs(bg=OUTLOOK_GRAY_DARK, extra_style="padding:16px 20px;text-align:center;")} align="center">
<img src="{TK_LOGO_URL}" width="180" alt="Thomas-Krenn Logo" style="display:block;margin:0 auto;border:0;height:auto;">
</td>
</tr>
<tr>
<td {_outlook_td_attrs(bg=OUTLOOK_GRAY_DARK, extra_style="padding:18px 20px;")}>
{_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_ORANGE, size="1", bold=True)}<br>
{_outlook_font("Die neuesten Wiki-Artikel", color=OUTLOOK_WHITE, size="5", bold=True)}<br>
{_outlook_font(f"{period_label} | {summary_text}", color="#DDDDDD", size="2")}
</td>
</tr>
<tr>
<td {_outlook_td_attrs(extra_style="padding:20px;")}>
<p style="margin:0 0 8px;">{_outlook_font("Servus,")}</p>
<p style="margin:0 0 16px;">{_outlook_font(f"hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {period_start} bis {period_end}.")}</p>
{contact_block}
{category_block}
{highlights_rows}
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}">
{body_rows}
{summary_row}
{editor_tip_row}
<tr>
<td {_outlook_td_attrs(extra_style="padding:20px 0 24px;")}>
{_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
</td>
</tr>
<tr>
<td {_outlook_td_attrs(extra_style=f"padding:20px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
{_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME} · Made with ❤️ by Samuel", color=OUTLOOK_MUTED, size="1")}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
def _outlook_highlights(items: list[dict[str, Any]]) -> str:
if not items:
return ""
rows = ""
for idx, item in enumerate(items[:3], start=1):
url = _article_url(item.get("title", ""))
rows += (
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_ORANGE_LIGHT, extra_style="padding:3px 0;")}>'
f'{_outlook_font(f"{idx}. ", color=OUTLOOK_ORANGE, bold=True)}'
f'{_outlook_link(item.get("title", "Ohne Titel"), url, color=OUTLOOK_TEXT, bold=False)}'
f'</td></tr>'
)
return (
f'<table width="100%" cellpadding="0" cellspacing="0" border="0" '
f'bgcolor="{COLOR_TK_ORANGE_LIGHT}" style="background-color:{COLOR_TK_ORANGE_LIGHT} !important;'
f'border:1px solid {COLOR_TK_ORANGE_BORDER};margin:0 0 12px;" data-ogsb="{COLOR_TK_ORANGE_LIGHT}">'
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_ORANGE_LIGHT, extra_style="padding:12px 14px 6px;")}>'
f'{_outlook_font("TOP HIGHLIGHTS", color=COLOR_TK_ORANGE_DARK, size="1", bold=True)}</td></tr>'
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_ORANGE_LIGHT, extra_style="padding:0 14px 12px;")}>'
f'<table width="100%" cellpadding="0" cellspacing="0" border="0">{rows}</table></td></tr>'
f'</table>'
)
def _outlook_editor_tip(editor_tip: str) -> str:
text = (editor_tip or "").strip()
if not text:
return ""
body = "<br>".join(_outlook_font(line) for line in text.splitlines())
return (
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_GRAY_LIGHT, extra_style=f"padding:14px 16px;margin-top:16px;border:1px solid {OUTLOOK_BORDER};")}>'
f'{_outlook_font("Redaktionsnotiz", color=OUTLOOK_HEADING, size="1", bold=True)}<br>'
f'{body}</td></tr>'
)
def _outlook_meta_line(item: dict[str, Any], display: DisplayOptions) -> str:
parts: list[str] = []
if display["show_date"]:
parts.append(f"Datum: {_format_ts(item.get('timestamp'))}")
if display["show_user"]:
parts.append(f"Benutzer: {item.get('user', '-')}")
if display["show_category"]:
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
parts.append(f"Kategorie: {cat}")
return " | ".join(parts)
def _outlook_section(
heading: str,
items: list[dict[str, Any]],
display: DisplayOptions,
empty_text: str,
kind: str = "edited",
) -> str:
accent = OUTLOOK_ORANGE if kind == "new" else OUTLOOK_GRAY_DARK
header = f"""<tr>
<td {_outlook_td_attrs(extra_style=f"padding:18px 0 8px;border-bottom:2px solid {accent};")}>
{_outlook_font(f"{heading} ({len(items)})", color=accent, size="3", bold=True)}
</td>
</tr>"""
if not items:
return header + f"""<tr>
<td {_outlook_td_attrs(extra_style="padding:8px 0;")}>
{_outlook_font(empty_text, color=OUTLOOK_MUTED)}
</td>
</tr>"""
cards = "\n".join(_outlook_article_card(idx, item, display, kind) for idx, item in enumerate(items, start=1))
return header + cards
def _outlook_article_card(idx: int, item: dict[str, Any], display: DisplayOptions, kind: str) -> str:
accent = OUTLOOK_ORANGE if kind == "new" else OUTLOOK_GRAY_DARK
eyebrow = "NEUER BEITRAG" if kind == "new" else "AKTUALISIERTER BEITRAG"
title = item.get("title", "Ohne Titel")
url = _article_url(item.get("title", ""))
meta = _outlook_meta_line(item, display)
meta_html = f'<br>{_outlook_font(meta, color=OUTLOOK_MUTED, size="1")}' if meta else ""
return f"""<tr>
<td {_outlook_td_attrs(extra_style="padding:8px 0;")}>
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;border:1px solid {OUTLOOK_BORDER};" data-ogsb="{OUTLOOK_WHITE}">
<tr>
<td {_outlook_td_attrs(bg=accent, width="6", extra_style="font-size:0;line-height:0;width:6px;")}>&nbsp;</td>
<td {_outlook_td_attrs(extra_style="padding:16px 20px;")}>
{_outlook_font(f"{idx}. {eyebrow}", color=accent, size="1", bold=True)}<br>
<span style="display:inline-block;margin:6px 0 2px;">{_outlook_link(title, url, color=OUTLOOK_TEXT, size="4")}</span>{meta_html}<br>
<span style="display:inline-block;margin-top:12px;">{_outlook_cta(url, "Zum Beitrag →")}</span>
</td>
</tr>
</table>
</td>
</tr>"""
# ---------------------------------------------------------------------------
# Plain-Text-Helfer
# ---------------------------------------------------------------------------
def _plain_section(
heading: str,
items: list[dict[str, Any]],
display: DisplayOptions,
empty_text: str,
) -> list[str]:
lines = [SUBLINE, f"{heading} ({len(items)})", SUBLINE, ""]
if not items:
lines.append(f" {empty_text}")
lines.append("")
return lines
for idx, item in enumerate(items, start=1):
lines.extend(_plain_item(idx, item, display))
return lines
def _plain_item(idx: int, item: dict[str, Any], display: DisplayOptions) -> list[str]:
title = item.get("title", "Ohne Titel")
url = _article_url(item.get("title", ""))
meta_parts: list[str] = []
if display["show_date"]:
meta_parts.append(_format_ts(item.get("timestamp")))
if display["show_user"]:
meta_parts.append(str(item.get("user", "-")))
if display["show_category"]:
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
meta_parts.append(cat)
lines = [f" {idx}. {title}", f" {url}"]
if meta_parts:
lines.append(f" ({' | '.join(meta_parts)})")
lines.append("")
return lines
def _plain_footer(
new_articles: list[dict[str, Any]],
edited_articles: list[dict[str, Any]],
) -> str:
total = len(new_articles) + len(edited_articles)
return "\n".join(
[
SUBLINE,
"ZUSAMMENFASSUNG",
SUBLINE,
f" Neue Artikel: {len(new_articles)}",
f" Bearbeitete Artikel: {len(edited_articles)}",
f" Gesamt: {total}",
"",
f"Wiki: {WIKI_HOME_URL}",
"",
SUBLINE,
"Dieser Newsletter wurde automatisch erstellt und ist nur für den internen",
f"Gebrauch bei {ORG_NAME} bestimmt.",
LINE,
]
)
# ---------------------------------------------------------------------------
# Gemeinsame Helfer
# ---------------------------------------------------------------------------
def _article_url(title: str) -> str:
return f"{WIKI_ARTICLE_BASE}{quote(title.replace(' ', '_'))}"
def article_url(title: str) -> str:
return _article_url(title)
def _intro_sentence(period_start: str, period_end: str) -> str:
return (
f"Im Thomas-Krenn-Wiki wurden im Zeitraum vom {period_start} bis {period_end} "
"folgende Artikel neu veröffentlicht bzw. aktualisiert:"
)
def _period_range(days: int) -> tuple[str, str]:
end = datetime.now()
start = end - timedelta(days=days)
return start.strftime("%d.%m.%Y"), end.strftime("%d.%m.%Y")
def _format_ts(value: str | None) -> str:
if not value:
return "-"
try:
dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
return dt.strftime("%d.%m.%Y %H:%M")
except ValueError:
return value