Files
TK-Wiki-Newsletter/app/services/smtp_sender.py
smueller 48410d39da Add editor exclusion defaults and newsletter filtering controls.
This makes it possible to exclude specific editors (e.g. Aranzinger) from edited-article results by default, including dashboard generation and scheduled SMTP runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 10:53:01 +02:00

155 lines
6.1 KiB
Python

import logging
import smtplib
from datetime import datetime
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr
from typing import Sequence
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.email_utils import parse_recipient_list
from app.models.system import SendLog
from app.services.config_store import get_config
logger = logging.getLogger("newsletter.smtp")
def get_smtp_settings(db: Session) -> dict[str, str]:
return {
"enabled": get_config(db, "smtp.enabled", "false"),
"host": get_config(db, "smtp.host", ""),
"port": get_config(db, "smtp.port", "587"),
"username": get_config(db, "smtp.username", ""),
"password": get_config(db, "smtp.password", ""),
"from_email": get_config(db, "smtp.from_email", ""),
"from_name": get_config(db, "smtp.from_name", ""),
"reply_to": get_config(db, "smtp.reply_to", ""),
"use_tls": get_config(db, "smtp.use_tls", "true"),
"recipients": get_config(db, "smtp.recipients", ""),
# Zeitplan
"schedule_enabled": get_config(db, "smtp.schedule_enabled", "false"),
"schedule_acknowledged": get_config(db, "smtp.schedule_acknowledged", "false"),
"schedule_frequency": get_config(db, "smtp.schedule_frequency", "monthly"),
"schedule_time": get_config(db, "smtp.schedule_time", "08:00"),
"schedule_weekdays": get_config(db, "smtp.schedule_weekdays", "0"),
"schedule_dom": get_config(db, "smtp.schedule_dom", "1"),
"last_scheduled_date": get_config(db, "smtp.last_scheduled_date", ""),
# Generierungs-Vorgaben für den geplanten Versand
"gen_period": get_config(db, "smtp.gen_period", "last_month"),
"gen_days": get_config(db, "smtp.gen_days", "30"),
"gen_article_type": get_config(db, "smtp.gen_article_type", "new"),
"gen_category": get_config(db, "smtp.gen_category", ""),
"gen_excluded_users": get_config(db, "smtp.gen_excluded_users", settings.default_excluded_users),
}
def schedule_is_due(settings: dict[str, str], now: datetime) -> bool:
"""Prüft, ob laut Zeitplan jetzt ein Versand fällig ist (mit Tages-Dedupe)."""
if settings["enabled"].lower() != "true":
return False
if (settings.get("schedule_enabled") or "false").lower() != "true":
return False
if (settings.get("schedule_acknowledged") or "false").lower() != "true":
return False
freq = (settings.get("schedule_frequency") or "off").lower()
if freq == "off":
return False
schedule_time = settings.get("schedule_time") or "08:00"
if now.strftime("%H:%M") < schedule_time:
return False
today = now.strftime("%Y-%m-%d")
if settings.get("last_scheduled_date") == today:
return False
if freq == "daily":
return True
if freq == "weekly":
weekdays = {d.strip() for d in (settings.get("schedule_weekdays") or "").split(",") if d.strip() != ""}
return str(now.weekday()) in weekdays
if freq == "monthly":
try:
dom = int(settings.get("schedule_dom") or "1")
except ValueError:
dom = 1
return now.day == max(1, min(dom, 28))
return False
def send_newsletter(
db: Session,
subject: str,
html_content: str,
text_content: str,
recipients: Sequence[str],
scheduled: bool = False,
) -> tuple[str, str]:
settings = get_smtp_settings(db)
if settings["enabled"].lower() != "true":
detail = "SMTP ist deaktiviert. Bitte in der SMTP-Konfiguration aktivieren."
_log(db, subject, recipients, "skipped", detail, scheduled)
return "skipped", detail
if not settings["host"] or not settings["from_email"] or not recipients:
detail = "SMTP unvollständig konfiguriert (Host, Absender oder Empfänger fehlen)."
_log(db, subject, recipients, "failed", detail, scheduled)
return "failed", detail
try:
validated_recipients = parse_recipient_list(",".join(recipients))
except ValueError as exc:
detail = f"Ungültige Empfängerliste: {exc}"
_log(db, subject, recipients, "failed", detail, scheduled)
return "failed", detail
msg = MIMEMultipart("alternative")
msg.attach(MIMEText(text_content, "plain", "utf-8"))
msg.attach(MIMEText(html_content, "html", "utf-8"))
msg["Subject"] = subject
if settings.get("from_name"):
msg["From"] = formataddr((settings["from_name"], settings["from_email"]))
else:
msg["From"] = settings["from_email"]
msg["To"] = settings["from_email"]
msg["Bcc"] = ", ".join(validated_recipients)
if settings.get("reply_to"):
msg["Reply-To"] = settings["reply_to"]
try:
port = int(settings["port"])
with smtplib.SMTP(settings["host"], port, timeout=20) as server:
if settings["use_tls"].lower() == "true":
server.starttls()
if settings["username"]:
server.login(settings["username"], settings["password"])
server.sendmail(settings["from_email"], validated_recipients, msg.as_string())
detail = f"Versand erfolgreich an {len(validated_recipients)} Empfänger."
_log(db, subject, validated_recipients, "sent", detail, scheduled)
return "sent", detail
except Exception as exc:
logger.exception("SMTP-Versand fehlgeschlagen")
detail = "Versandfehler: E-Mail konnte nicht zugestellt werden. Details stehen im Server-Log."
_log(db, subject, validated_recipients, "failed", detail, scheduled)
return "failed", detail
def mark_scheduled_sent(db: Session, now: datetime) -> None:
from app.services.config_store import set_config
set_config(db, "smtp.last_scheduled_date", now.strftime("%Y-%m-%d"))
def _log(db: Session, subject: str, recipients: Sequence[str], status: str, detail: str, scheduled: bool) -> None:
db.add(
SendLog(
subject=subject,
recipients=", ".join(recipients) if recipients else "-",
status=status,
detail=detail,
scheduled=scheduled,
)
)
db.commit()