#!/usr/bin/env python3 # Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de) # Licensed under the MIT License. See LICENSE file in project root for details. """Sendet eine tägliche E-Mail mit Geburtstagskindern pro Account aus der contacts-Tabelle. Wird per Cron einmal täglich um MAIL_SEND_HOUR aufgerufen. Verhindert Doppelversand am selben Tag pro Account über die Tabelle birthday_mail_log.""" import logging import smtplib import sys from datetime import date, datetime from email.message import EmailMessage import db from config import Config from utils import fmt_birthday_age, fmt_birthday_short logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("mailer") def fetch_todays_birthdays_for_account(conn, account_name: str, target_date: date) -> list[dict]: with conn.cursor() as cur: cur.execute( """SELECT id, account, full_name, given_name, middle_name, family_name, prefix, suffix, birthday, organization, photo_url FROM contacts WHERE account = %s AND birthday IS NOT NULL AND MONTH(birthday) = %s AND DAY(birthday) = %s ORDER BY given_name, family_name, full_name""", (account_name, target_date.month, target_date.day), ) rows = cur.fetchall() for b in rows: if not b.get("full_name"): b["full_name"] = db._build_full_name(b) or "(Kein Name)" return rows def already_sent_today(conn, account: str) -> bool: today = datetime.now(Config.TIMEZONE).date() with conn.cursor() as cur: cur.execute( "SELECT 1 FROM birthday_mail_log WHERE account = %s AND sent_date = %s", (account, today), ) return cur.fetchone() is not None def log_sent(conn, account: str, count: int): today = datetime.now(Config.TIMEZONE).date() with conn.cursor() as cur: cur.execute( "INSERT INTO birthday_mail_log (account, sent_date, contacts_count) VALUES (%s, %s, %s)", (account, today, count), ) conn.commit() def build_message(account_name: str, birthdays: list[dict], target_date: date | None = None) -> EmailMessage: today = target_date or datetime.now(Config.TIMEZONE).date() msg = EmailMessage() msg["From"] = Config.MAIL_FROM if not birthdays: msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): keine" msg.set_content("Heute hat niemand aus deinen Kontakten Geburtstag.") return msg msg["Subject"] = f"Geburtstage heute ({today.isoformat()}): {len(birthdays)}" plain_lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""] for b in birthdays: date_str = fmt_birthday_short(b["birthday"]) age = fmt_birthday_age(b["birthday"], today) age_str = f" · {age} Jahre" if age is not None else "" line = f"- {b['full_name']} · {date_str}{age_str}" if b.get("organization"): line += f" ({b['organization']})" if Config.WEB_URL: line += f" → {Config.WEB_URL.rstrip('/')}/contacts/{b['id']}" plain_lines.append(line) if Config.WEB_URL: plain_lines.extend(["", f"Alle Kontakte ansehen: {Config.WEB_URL}"]) msg.set_content("\n".join(plain_lines)) cards_html = "" for b in birthdays: date_str = fmt_birthday_short(b["birthday"]) age = fmt_birthday_age(b["birthday"], today) age_str = f' · {age} Jahre' if age is not None else "" contact_link = f"{Config.WEB_URL.rstrip('/')}/contacts/{b['id']}" if Config.WEB_URL else "" org_html = f'
{b["organization"]}
' if b.get("organization") else "" photo_cell = "" if b.get("photo_url"): photo_cell = f'' if contact_link: content_html = f"""
{b["full_name"]}
{org_html}
🎂 {date_str}{age_str}
""" else: content_html = f"""
{b["full_name"]}
{org_html}
🎂 {date_str}{age_str}
""" cards_html += f""" {photo_cell}
{content_html}
""" link_html = "" if Config.WEB_URL: link_html = f"""
Alle Kontakte ansehen
""" html = f"""

Geburtstage heute

{today.strftime('%d.%m.%Y')} · {len(birthdays)} Kontakte

{cards_html}
{link_html}
""" msg.add_alternative(html, subtype="html") return msg def send_message(msg: EmailMessage): if Config.SMTP_USE_TLS: with smtplib.SMTP(Config.SMTP_HOST, Config.SMTP_PORT) as server: server.starttls() if Config.SMTP_USER: server.login(Config.SMTP_USER, Config.SMTP_PASSWORD) server.send_message(msg) else: with smtplib.SMTP(Config.SMTP_HOST, Config.SMTP_PORT) as server: if Config.SMTP_USER: server.login(Config.SMTP_USER, Config.SMTP_PASSWORD) server.send_message(msg) def main() -> int: if not Config.MAILER_ENABLED: logger.info("Mailer ist deaktiviert (MAILER_ENABLED=false), überspringe Lauf") return 0 try: Config.validate_db() Config.validate_mailer() except RuntimeError as exc: logger.error(str(exc)) return 1 accounts = Config.load_accounts() mail_accounts = [a for a in accounts if a.birthday_mail_to] if not mail_accounts: logger.info("Keine Accounts mit birthday_mail_to konfiguriert, überspringe Lauf") return 0 today = datetime.now(Config.TIMEZONE).date() sent_count = 0 errors = 0 with db.get_connection() as conn: for account in mail_accounts: if already_sent_today(conn, account.name): logger.info("Geburtstagsmail für Account '%s' wurde heute bereits versendet, überspringe", account.name) continue birthdays = fetch_todays_birthdays_for_account(conn, account.name, today) if not birthdays: logger.info("Keine Geburtstage heute für Account '%s', überspringe", account.name) continue msg = build_message(account.name, birthdays, today) msg["To"] = account.birthday_mail_to try: send_message(msg) except Exception: logger.exception("Versand der Geburtstagsmail für Account '%s' fehlgeschlagen", account.name) errors += 1 continue log_sent(conn, account.name, len(birthdays)) sent_count += 1 logger.info("Geburtstagsmail versendet für Account '%s': %d Kontakte", account.name, len(birthdays)) logger.info("Mailer-Lauf abgeschlossen: %d Mails versendet, %d Fehler", sent_count, errors) return 1 if errors > 0 else 0 if __name__ == "__main__": sys.exit(main())