initial commit

This commit is contained in:
2026-08-04 21:02:28 +02:00
parent fc1bbe2a85
commit d14690c525
27 changed files with 1810 additions and 0 deletions
View File
+42
View File
@@ -0,0 +1,42 @@
"""Authelia-Integration: liest den vom Reverse-Proxy weitergereichten
Remote-User-Header und mappt ihn per accounts.json auf einen internen
Account-Namen. Kein eigenes Login, Authelia übernimmt die eigentliche
Authentifizierung vorgeschaltet."""
from fastapi import Header, HTTPException, status
from config import Config
def get_current_user(remote_user: str | None = Header(default=None, alias="Remote-User")) -> str:
"""Extrahiert den Authelia-Benutzernamen aus dem konfigurierten Header.
Der Header-Name ist über AUTH_REMOTE_USER_HEADER konfigurierbar, FastAPI
bindet hier auf den Default 'Remote-User', siehe Hinweis in README.md
falls du einen anderen Header-Namen in Authelia/nginx konfiguriert hast."""
if not remote_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Kein Remote-User-Header vom Reverse-Proxy erhalten. "
"Läuft die Anwendung hinter Authelia mit korrektem auth_request-Setup?",
)
return remote_user
def resolve_account_for_user(authelia_user: str) -> tuple[str | None, bool]:
"""Gibt (account_name, is_admin) zurück.
account_name ist None, wenn der User als Admin auf ALLE Accounts zugreifen darf.
Ist der User weder gemappt noch Admin, wird eine 403 geworfen."""
admins = Config.load_admin_users()
user_map = Config.load_authelia_user_map()
is_admin = authelia_user in admins
if is_admin:
return None, True
account_name = user_map.get(authelia_user)
if not account_name:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Authelia-Benutzer '{authelia_user}' ist keinem Account in accounts.json zugeordnet.",
)
return account_name, False
+176
View File
@@ -0,0 +1,176 @@
"""FastAPI-App für die interne Web-Ansicht/API der iCloud-Kontakte.
Läuft im selben Image wie der Sync-Container, wird aber über einen
eigenen Docker-Compose-Service mit abweichendem Startbefehl gestartet
(uvicorn statt sync/mailer). Zugriff ausschließlich über einen
vorgeschalteten Reverse-Proxy mit Authelia, der den eingeloggten
Benutzernamen im Remote-User-Header mitschickt."""
import json
import logging
from datetime import date
from fastapi import FastAPI, Depends, Query, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from config import Config
import db
from api.auth import get_current_user, resolve_account_for_user
from api.schemas import ContactListResponse, ContactOut, SyncRunOut
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("api")
app = FastAPI(title="iCloud Contacts Sync Interne API", version="1.0.0")
templates = Jinja2Templates(directory="api/templates")
def _row_to_contact_out(row: dict) -> dict:
row = dict(row)
for field in ["emails", "phones", "addresses", "urls", "categories"]:
raw = row.get(field)
row[field] = json.loads(raw) if raw else []
row["updated_at"] = str(row["updated_at"])
return row
def _account_filter_clause(account_name: str | None) -> tuple[str, list]:
if account_name is None:
return "", []
return "WHERE account = %s", [account_name]
@app.get("/api/health")
def health():
return {"status": "ok"}
@app.get("/api/contacts", response_model=ContactListResponse)
def list_contacts(
request: Request,
q: str | None = Query(default=None, description="Freitextsuche über Name, Organisation, E-Mail"),
limit: int = Query(default=50, le=200),
offset: int = Query(default=0, ge=0),
current_user: str = Depends(get_current_user),
):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
search_clause = ""
if q:
search_op = "AND" if where_clause else "WHERE"
search_clause = f" {search_op} (full_name LIKE %s OR organization LIKE %s OR emails LIKE %s)"
like = f"%{q}%"
params.extend([like, like, like])
with conn.cursor() as cur:
cur.execute(f"SELECT COUNT(*) AS total FROM contacts {where_clause}{search_clause}", params)
total = cur.fetchone()["total"]
cur.execute(
f"""SELECT id, account, uid, full_name, given_name, family_name, organization,
job_title, birthday, notes, emails, phones, addresses, urls, categories, updated_at
FROM contacts {where_clause}{search_clause}
ORDER BY full_name
LIMIT %s OFFSET %s""",
params + [limit, offset],
)
rows = cur.fetchall()
items = [_row_to_contact_out(r) for r in rows]
return {"total": total, "items": items}
@app.get("/api/contacts/{contact_id}", response_model=ContactOut)
def get_contact(contact_id: int, current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
id_clause = "AND id = %s" if where_clause else "WHERE id = %s"
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, uid, full_name, given_name, family_name, organization,
job_title, birthday, notes, emails, phones, addresses, urls, categories, updated_at
FROM contacts {where_clause} {id_clause}""",
params + [contact_id],
)
row = cur.fetchone()
if not row:
return {}
return _row_to_contact_out(row)
@app.get("/api/contacts/birthdays/today", response_model=list[ContactOut])
def birthdays_today(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
today = date.today()
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
month_day_clause = "AND MONTH(birthday) = %s AND DAY(birthday) = %s" if where_clause \
else "WHERE MONTH(birthday) = %s AND DAY(birthday) = %s"
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, uid, full_name, given_name, family_name, organization,
job_title, birthday, notes, emails, phones, addresses, urls, categories, updated_at
FROM contacts {where_clause} {month_day_clause}
ORDER BY full_name""",
params + [today.month, today.day],
)
rows = cur.fetchall()
return [_row_to_contact_out(r) for r in rows]
@app.get("/api/sync-runs", response_model=list[SyncRunOut])
def list_sync_runs(current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, account, sync_type, started_at, finished_at, status,
contacts_upserted, contacts_deleted, error_message
FROM sync_runs {where_clause}
ORDER BY started_at DESC
LIMIT 50""",
params,
)
rows = cur.fetchall()
for r in rows:
r["started_at"] = str(r["started_at"])
r["finished_at"] = str(r["finished_at"]) if r["finished_at"] else None
return rows
@app.get("/", response_class=HTMLResponse)
def web_index(request: Request, current_user: str = Depends(get_current_user)):
account_name, is_admin = resolve_account_for_user(current_user)
with db.get_connection() as conn:
where_clause, params = _account_filter_clause(account_name)
with conn.cursor() as cur:
cur.execute(
f"""SELECT id, full_name, organization, birthday, account
FROM contacts {where_clause}
ORDER BY full_name
LIMIT 200""",
params,
)
rows = cur.fetchall()
return templates.TemplateResponse(
"index.html",
{
"request": request,
"current_user": current_user,
"is_admin": is_admin,
"account_name": account_name or "alle Accounts",
"contacts": rows,
},
)
+41
View File
@@ -0,0 +1,41 @@
from datetime import date
from pydantic import BaseModel
class ContactOut(BaseModel):
id: int
account: str
uid: str
full_name: str | None
given_name: str | None
family_name: str | None
organization: str | None
job_title: str | None
birthday: date | None
notes: str | None
emails: list
phones: list
addresses: list
urls: list
categories: list
updated_at: str
class Config:
from_attributes = True
class ContactListResponse(BaseModel):
total: int
items: list[ContactOut]
class SyncRunOut(BaseModel):
id: str
account: str
sync_type: str
started_at: str
finished_at: str | None
status: str
contacts_upserted: int | None
contacts_deleted: int | None
error_message: str | None
+38
View File
@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Kontakte {{ account_name }}</title>
<style>
body { font-family: sans-serif; margin: 2rem; background: #111; color: #eee; }
h1 { font-size: 1.4rem; }
table { border-collapse: collapse; width: 100%; margin-top: 1rem; }
th, td { border-bottom: 1px solid #333; padding: 0.4rem 0.6rem; text-align: left; }
th { background: #1c1c1c; }
.meta { color: #999; font-size: 0.85rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<h1>Kontakte</h1>
<div class="meta">
Angemeldet als <strong>{{ current_user }}</strong>
{% if is_admin %}(Admin, sieht alle Accounts){% else %}(Account: {{ account_name }}){% endif %}
· {{ contacts|length }} Kontakte (max. 200 angezeigt)
</div>
<table>
<thead>
<tr><th>Name</th><th>Organisation</th><th>Geburtstag</th>{% if is_admin %}<th>Account</th>{% endif %}</tr>
</thead>
<tbody>
{% for c in contacts %}
<tr>
<td>{{ c.full_name or "-" }}</td>
<td>{{ c.organization or "-" }}</td>
<td>{{ c.birthday or "-" }}</td>
{% if is_admin %}<td>{{ c.account }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
"""
CardDAV-Client für iCloud (RFC 6352) mit Delta-Sync über sync-collection (RFC 6578).
Ablauf pro Account:
1. PROPFIND auf die Basis-URL -> current-user-principal ermitteln
2. PROPFIND auf das Principal -> addressbook-home-set ermitteln
3. PROPFIND auf das Addressbook-Home -> tatsächliche Addressbook-Collection finden
4. REPORT sync-collection mit gespeichertem sync-token -> nur Änderungen abrufen
(leerer sync-token beim allerersten Lauf -> voller initialer Abruf)
iCloud-Tokens sind laut Google/Apple-Doku ca. 29 Tage gültig; läuft ein Token ab,
antwortet der Server mit 403 valid-sync-token, dann fällt der Client automatisch
auf einen vollständigen Re-Sync zurück.
"""
import logging
from xml.etree import ElementTree as ET
from urllib.parse import urljoin
import requests
logger = logging.getLogger(__name__)
ICLOUD_BASE_URL = "https://contacts.icloud.com/"
NS = {
"d": "DAV:",
"card": "urn:ietf:params:xml:ns:carddav",
}
class SyncTokenInvalid(Exception):
"""Wird geworfen, wenn der Server den gespeicherten sync-token nicht mehr akzeptiert."""
class CardDAVClient:
def __init__(self, base_url: str, username: str, password: str, timeout: int = 30):
self.base_url = base_url
self.auth = (username, password)
self.timeout = timeout
self.session = requests.Session()
def _request(self, method: str, url: str, body: str, depth: str = "0"):
headers = {"Depth": depth, "Content-Type": "application/xml; charset=utf-8"}
resp = self.session.request(
method, url, data=body, headers=headers, auth=self.auth, timeout=self.timeout,
)
if resp.status_code == 403 and "valid-sync-token" in resp.text:
raise SyncTokenInvalid("sync-token vom Server abgelehnt")
resp.raise_for_status()
return ET.fromstring(resp.content)
def discover_principal(self) -> str:
body = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:current-user-principal/></d:prop>
</d:propfind>"""
root = self._request("PROPFIND", self.base_url, body)
href = root.find(".//d:current-user-principal/d:href", NS)
if href is None:
raise RuntimeError("current-user-principal nicht gefunden")
return self._absolute(href.text)
def discover_addressbook_home(self, principal_url: str) -> str:
body = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop><card:addressbook-home-set/></d:prop>
</d:propfind>"""
root = self._request("PROPFIND", principal_url, body)
href = root.find(".//card:addressbook-home-set/d:href", NS)
if href is None:
raise RuntimeError("addressbook-home-set nicht gefunden")
return self._absolute(href.text)
def discover_addressbook_collection(self, home_url: str) -> str:
body = """<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop><d:resourcetype/><d:displayname/></d:prop>
</d:propfind>"""
root = self._request("PROPFIND", home_url, body, depth="1")
for response in root.findall("d:response", NS):
resourcetype = response.find(".//d:resourcetype", NS)
if resourcetype is not None and any(child.tag.endswith("addressbook") for child in resourcetype):
href = response.find("d:href", NS)
if href is not None:
return self._absolute(href.text)
raise RuntimeError("Keine addressbook-Collection gefunden")
def sync_collection(self, collection_url: str, sync_token: str | None):
"""
Führt REPORT sync-collection aus. Gibt (changed_or_new_vcards, deleted_hrefs, new_sync_token) zurück.
changed_or_new_vcards: list[str] roher vCard-Text
deleted_hrefs: list[str] Hrefs von gelöschten Kontakten (status 404)
"""
token_element = f"<d:sync-token>{sync_token}</d:sync-token>" if sync_token else "<d:sync-token/>"
body = f"""<?xml version="1.0" encoding="utf-8" ?>
<d:sync-collection xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
{token_element}
<d:sync-level>1</d:sync-level>
<d:prop><d:getetag/><card:address-data/></d:prop>
</d:sync-collection>"""
root = self._request("REPORT", collection_url, body, depth="1")
vcards, deleted_hrefs = [], []
for response in root.findall("d:response", NS):
status_el = response.find(".//d:status", NS)
status_text = status_el.text if status_el is not None else ""
href_el = response.find("d:href", NS)
href = href_el.text if href_el is not None else None
if "404" in status_text:
if href:
deleted_hrefs.append(href)
continue
data = response.find(".//card:address-data", NS)
if data is not None and data.text:
vcards.append(data.text)
new_token_el = root.find("d:sync-token", NS)
new_token = new_token_el.text if new_token_el is not None else None
return vcards, deleted_hrefs, new_token
def fetch_all_vcards(self, collection_url: str) -> list[str]:
"""Fallback für den allerersten, vollen Abruf über addressbook-query."""
body = """<?xml version="1.0" encoding="utf-8" ?>
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
<d:prop><d:getetag/><card:address-data/></d:prop>
<card:filter/>
</card:addressbook-query>"""
root = self._request("REPORT", collection_url, body, depth="1")
vcards = []
for response in root.findall("d:response", NS):
data = response.find(".//card:address-data", NS)
if data is not None and data.text:
vcards.append(data.text)
return vcards
def discover_collection(self) -> str:
principal = self.discover_principal()
home = self.discover_addressbook_home(principal)
collection = self.discover_addressbook_collection(home)
logger.info("Addressbook-Collection gefunden: %s", collection)
return collection
def _absolute(self, href: str) -> str:
if href.startswith("http"):
return href
return urljoin(self.base_url, href)
+112
View File
@@ -0,0 +1,112 @@
import os
import json
from dotenv import load_dotenv
load_dotenv()
ICLOUD_BASE_URL = "https://contacts.icloud.com/"
class Account:
def __init__(self, name: str, apple_email: str, apple_app_password: str, authelia_user: str | None):
self.name = name
self.apple_email = apple_email
self.apple_app_password = apple_app_password
self.authelia_user = authelia_user
class Config:
MARIADB_HOST = os.environ.get("MARIADB_HOST", "mariadb.internal")
MARIADB_PORT = int(os.environ.get("MARIADB_PORT", "3306"))
MARIADB_DATABASE = os.environ.get("MARIADB_DATABASE", "contacts")
MARIADB_USER = os.environ.get("MARIADB_USER", "")
MARIADB_PASSWORD = os.environ.get("MARIADB_PASSWORD", "")
ACCOUNTS_CONFIG_PATH = os.environ.get("ACCOUNTS_CONFIG_PATH", "/app/config/accounts.json")
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
SOURCE_NAME = "icloud"
MAILER_ENABLED = os.environ.get("MAILER_ENABLED", "false").lower() == "true"
SMTP_HOST = os.environ.get("SMTP_HOST", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USER = os.environ.get("SMTP_USER", "")
SMTP_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
SMTP_USE_TLS = os.environ.get("SMTP_USE_TLS", "true").lower() == "true"
MAIL_FROM = os.environ.get("MAIL_FROM", "")
MAIL_TO = os.environ.get("MAIL_TO", "")
# Authelia liefert den eingeloggten Benutzer per Header, der vom
# vorgeschalteten nginx/traefik als Remote-User weitergereicht wird.
AUTH_REMOTE_USER_HEADER = os.environ.get("AUTH_REMOTE_USER_HEADER", "Remote-User")
API_HOST = os.environ.get("API_HOST", "0.0.0.0")
API_PORT = int(os.environ.get("API_PORT", "8000"))
@classmethod
def validate_db(cls):
missing = [n for n, v in [
("MARIADB_USER", cls.MARIADB_USER),
("MARIADB_PASSWORD", cls.MARIADB_PASSWORD),
] if not v]
if missing:
raise RuntimeError(f"Fehlende Umgebungsvariablen: {', '.join(missing)}")
@classmethod
def validate_mailer(cls):
missing = [n for n, v in [
("SMTP_HOST", cls.SMTP_HOST),
("MAIL_FROM", cls.MAIL_FROM),
("MAIL_TO", cls.MAIL_TO),
] if not v]
if missing:
raise RuntimeError(f"Fehlende Mailer-Umgebungsvariablen: {', '.join(missing)}")
@classmethod
def _load_raw_accounts_config(cls) -> dict:
if not os.path.exists(cls.ACCOUNTS_CONFIG_PATH):
raise RuntimeError(
f"Accounts-Konfiguration nicht gefunden: {cls.ACCOUNTS_CONFIG_PATH}. "
f"Kopiere config/accounts.json.example nach config/accounts.json."
)
with open(cls.ACCOUNTS_CONFIG_PATH, "r", encoding="utf-8") as f:
return json.load(f)
@classmethod
def load_accounts(cls) -> list[Account]:
data = cls._load_raw_accounts_config()
accounts_raw = data.get("accounts", [])
if not accounts_raw:
raise RuntimeError("accounts.json enthält keine Accounts")
seen_names = set()
accounts = []
for entry in accounts_raw:
name = entry.get("name")
email = entry.get("apple_email")
pwd = entry.get("apple_app_password")
authelia_user = entry.get("authelia_user")
if not all([name, email, pwd]):
raise RuntimeError(f"Unvollständiger Account-Eintrag: {entry}")
if name in seen_names:
raise RuntimeError(f"Account-Name '{name}' ist nicht eindeutig")
seen_names.add(name)
accounts.append(Account(name, email, pwd, authelia_user))
return accounts
@classmethod
def load_admin_users(cls) -> set[str]:
data = cls._load_raw_accounts_config()
return set(data.get("admins", []))
@classmethod
def load_authelia_user_map(cls) -> dict[str, str]:
"""Gibt {authelia_user: account_name} zurück, genutzt von der Web-Ansicht/API."""
accounts = cls.load_accounts()
mapping = {}
for acc in accounts:
if acc.authelia_user:
if acc.authelia_user in mapping:
raise RuntimeError(f"authelia_user '{acc.authelia_user}' ist mehreren Accounts zugeordnet")
mapping[acc.authelia_user] = acc.name
return mapping
+110
View File
@@ -0,0 +1,110 @@
"""MariaDB-Anbindung: Delta-Sync-Strategie (Upsert für Änderungen, gezieltes Löschen für Removals)."""
import logging
import uuid
from contextlib import contextmanager
import pymysql
from pymysql.cursors import DictCursor
from config import Config
logger = logging.getLogger(__name__)
@contextmanager
def get_connection():
conn = pymysql.connect(
host=Config.MARIADB_HOST, port=Config.MARIADB_PORT,
user=Config.MARIADB_USER, password=Config.MARIADB_PASSWORD,
database=Config.MARIADB_DATABASE, cursorclass=DictCursor, autocommit=False,
)
try:
yield conn
finally:
conn.close()
def get_sync_token(conn, account: str) -> str | None:
with conn.cursor() as cur:
cur.execute("SELECT sync_token FROM sync_state WHERE account = %s", (account,))
row = cur.fetchone()
return row["sync_token"] if row else None
def save_sync_token(conn, account: str, sync_token: str):
with conn.cursor() as cur:
cur.execute(
"""INSERT INTO sync_state (account, sync_token) VALUES (%s, %s)
ON DUPLICATE KEY UPDATE sync_token = %s""",
(account, sync_token, sync_token),
)
conn.commit()
def clear_sync_token(conn, account: str):
with conn.cursor() as cur:
cur.execute("DELETE FROM sync_state WHERE account = %s", (account,))
conn.commit()
def start_sync_run(conn, account: str, sync_type: str) -> str:
run_id = str(uuid.uuid4())
with conn.cursor() as cur:
cur.execute(
"INSERT INTO sync_runs (id, account, sync_type, status) VALUES (%s, %s, %s, 'running')",
(run_id, account, sync_type),
)
conn.commit()
return run_id
def finish_sync_run(conn, run_id: str, status: str, upserted: int = None, deleted: int = None, error_message: str = None):
with conn.cursor() as cur:
cur.execute(
"""UPDATE sync_runs SET status=%s, contacts_upserted=%s, contacts_deleted=%s,
error_message=%s, finished_at=NOW() WHERE id=%s""",
(status, upserted, deleted, error_message, run_id),
)
conn.commit()
def upsert_contacts(conn, contacts: list[dict], run_id: str):
if not contacts:
return
with conn.cursor() as cur:
for c in contacts:
c["sync_run_id"] = run_id
cols = list(c.keys())
placeholders = ", ".join(["%s"] * len(cols))
update_clause = ", ".join(f"{col}=VALUES({col})" for col in cols if col not in ("account", "uid"))
sql = (
f"INSERT INTO contacts ({', '.join(cols)}) VALUES ({placeholders}) "
f"ON DUPLICATE KEY UPDATE {update_clause}"
)
cur.execute(sql, list(c.values()))
conn.commit()
def delete_contacts_by_href_uids(conn, account: str, uids: list[str]):
if not uids:
return
with conn.cursor() as cur:
placeholders = ", ".join(["%s"] * len(uids))
cur.execute(
f"DELETE FROM contacts WHERE account = %s AND uid IN ({placeholders})",
[account] + uids,
)
conn.commit()
def replace_all_contacts_for_account(conn, account: str, contacts: list[dict], run_id: str):
"""Voller Re-Sync für einen Account (initialer Lauf oder Recovery nach ungültigem sync-token)."""
with conn.cursor() as cur:
cur.execute("DELETE FROM contacts WHERE account = %s", (account,))
for c in contacts:
c["sync_run_id"] = run_id
cols = ", ".join(c.keys())
placeholders = ", ".join(["%s"] * len(c))
cur.execute(f"INSERT INTO contacts ({cols}) VALUES ({placeholders})", list(c.values()))
conn.commit()
logger.info("Voller Re-Sync für Account %s abgeschlossen: %d Kontakte", account, len(contacts))
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Sendet eine tägliche E-Mail mit allen heutigen Geburtstagskindern aus der
contacts-Tabelle (über alle Accounts hinweg). Wird per Cron einmal täglich
um MAIL_SEND_HOUR aufgerufen. Verhindert Doppelversand am selben Tag über
die Tabelle birthday_mail_log."""
import logging
import smtplib
import sys
from datetime import date
from email.message import EmailMessage
from config import Config
import db
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("mailer")
def fetch_todays_birthdays(conn) -> list[dict]:
today = date.today()
with conn.cursor() as cur:
cur.execute(
"""SELECT account, full_name, birthday
FROM contacts
WHERE birthday IS NOT NULL
AND MONTH(birthday) = %s
AND DAY(birthday) = %s
ORDER BY full_name""",
(today.month, today.day),
)
return cur.fetchall()
def already_sent_today(conn) -> bool:
today = date.today()
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM birthday_mail_log WHERE sent_date = %s", (today,))
return cur.fetchone() is not None
def log_sent(conn, count: int):
today = date.today()
with conn.cursor() as cur:
cur.execute(
"INSERT INTO birthday_mail_log (sent_date, contacts_count) VALUES (%s, %s)",
(today, count),
)
conn.commit()
def build_message(birthdays: list[dict]) -> EmailMessage:
today = date.today()
msg = EmailMessage()
msg["From"] = Config.MAIL_FROM
msg["To"] = Config.MAIL_TO
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)}"
lines = [f"Heutige Geburtstage ({today.isoformat()}):", ""]
for b in birthdays:
age = today.year - b["birthday"].year
lines.append(f"- {b['full_name']} (wird {age}, Account: {b['account']})")
msg.set_content("\n".join(lines))
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
with db.get_connection() as conn:
if already_sent_today(conn):
logger.info("Geburtstagsmail wurde heute bereits versendet, überspringe")
return 0
birthdays = fetch_todays_birthdays(conn)
msg = build_message(birthdays)
try:
send_message(msg)
except Exception:
logger.exception("Versand der Geburtstagsmail fehlgeschlagen")
return 1
log_sent(conn, len(birthdays))
logger.info("Geburtstagsmail versendet: %d Kontakte", len(birthdays))
return 0
if __name__ == "__main__":
sys.exit(main())
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Einstiegspunkt für einen Sync-Lauf über alle konfigurierten Accounts.
Für jeden Account wird ein Delta-Sync per CardDAV sync-collection (RFC 6578)
durchgeführt. Beim allerersten Lauf eines Accounts (kein gespeicherter
sync-token) sowie nach einem vom Server abgelehnten Token erfolgt ein
vollständiger Re-Sync."""
import logging
import sys
from config import Config
from carddav_client import CardDAVClient, ICLOUD_BASE_URL, SyncTokenInvalid
from vcard_parser import parse_vcard
import db
logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("sync")
def sync_account(conn, account, href_to_uid_cache: dict):
client = CardDAVClient(ICLOUD_BASE_URL, account.apple_email, account.apple_app_password)
collection_url = client.discover_collection()
stored_token = db.get_sync_token(conn, account.name)
sync_type = "delta" if stored_token else "initial"
run_id = db.start_sync_run(conn, account.name, sync_type)
try:
if not stored_token:
logger.info("[%s] Kein sync-token vorhanden, führe initialen Full-Sync aus", account.name)
raw_vcards = client.fetch_all_vcards(collection_url)
contacts = [c for c in (parse_vcard(v, account.name) for v in raw_vcards) if c]
db.replace_all_contacts_for_account(conn, account.name, contacts, run_id)
_, _, new_token = client.sync_collection(collection_url, None)
if new_token:
db.save_sync_token(conn, account.name, new_token)
db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=0)
logger.info("[%s] Initialer Sync abgeschlossen: %d Kontakte", account.name, len(contacts))
return
try:
changed_vcards, deleted_hrefs, new_token = client.sync_collection(collection_url, stored_token)
except SyncTokenInvalid:
logger.warning("[%s] sync-token vom Server abgelehnt, führe vollen Re-Sync aus", account.name)
db.clear_sync_token(conn, account.name)
raw_vcards = client.fetch_all_vcards(collection_url)
contacts = [c for c in (parse_vcard(v, account.name) for v in raw_vcards) if c]
db.replace_all_contacts_for_account(conn, account.name, contacts, run_id)
_, _, new_token = client.sync_collection(collection_url, None)
if new_token:
db.save_sync_token(conn, account.name, new_token)
db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=0)
logger.info("[%s] Re-Sync abgeschlossen: %d Kontakte", account.name, len(contacts))
return
contacts = [c for c in (parse_vcard(v, account.name) for v in changed_vcards) if c]
db.upsert_contacts(conn, contacts, run_id)
deleted_uids = [href.rstrip("/").rsplit("/", 1)[-1].replace(".vcf", "") for href in deleted_hrefs]
db.delete_contacts_by_href_uids(conn, account.name, deleted_uids)
if new_token:
db.save_sync_token(conn, account.name, new_token)
db.finish_sync_run(conn, run_id, "success", upserted=len(contacts), deleted=len(deleted_uids))
logger.info(
"[%s] Delta-Sync abgeschlossen: %d geändert/neu, %d gelöscht",
account.name, len(contacts), len(deleted_uids),
)
except Exception as exc:
logger.exception("[%s] Sync-Lauf %s fehlgeschlagen", account.name, run_id)
db.finish_sync_run(conn, run_id, "failed", error_message=str(exc))
raise
def main() -> int:
try:
Config.validate_db()
accounts = Config.load_accounts()
except RuntimeError as exc:
logger.error(str(exc))
return 1
exit_code = 0
with db.get_connection() as conn:
for account in accounts:
try:
sync_account(conn, account, {})
except Exception:
exit_code = 1
return exit_code
if __name__ == "__main__":
sys.exit(main())
+86
View File
@@ -0,0 +1,86 @@
"""Parst rohen vCard-Text in ein flaches dict, passend zum contacts-Schema."""
import json
import logging
import vobject
logger = logging.getLogger(__name__)
def _get(vcard, attr, default=None):
return getattr(vcard, attr).value if hasattr(vcard, attr) else default
def parse_vcard(raw_text: str, account: str) -> dict | None:
try:
vcard = vobject.readOne(raw_text)
except Exception as exc:
logger.warning("vCard konnte nicht geparst werden: %s", exc)
return None
uid = _get(vcard, "uid")
if not uid:
logger.warning("vCard ohne UID übersprungen")
return None
n = getattr(vcard, "n", None)
given_name = n.value.given if n else None
family_name = n.value.family if n else None
middle_name = n.value.additional if n else None
prefix = n.value.prefix if n else None
suffix = n.value.suffix if n else None
emails = [{"type": ",".join(e.type_paramlist) if e.type_paramlist else "other", "value": e.value}
for e in getattr(vcard, "email_list", [])]
phones = [{"type": ",".join(t.type_paramlist) if t.type_paramlist else "other", "value": t.value}
for t in getattr(vcard, "tel_list", [])]
addresses = []
for a in getattr(vcard, "adr_list", []):
v = a.value
addresses.append({
"type": ",".join(a.type_paramlist) if a.type_paramlist else "other",
"street": v.street, "city": v.city, "region": v.region,
"zip": v.code, "country": v.country,
})
urls = [{"type": ",".join(u.type_paramlist) if u.type_paramlist else "other", "value": u.value}
for u in getattr(vcard, "url_list", [])]
categories = [c.strip() for c in vcard.categories.value] if hasattr(vcard, "categories") else []
birthday = str(vcard.bday.value)[:10] if hasattr(vcard, "bday") else None
org = None
if hasattr(vcard, "org"):
org_val = vcard.org.value
org = org_val[0] if isinstance(org_val, list) else org_val
return {
"account": account,
"uid": uid,
"etag": None,
"full_name": _get(vcard, "fn"),
"given_name": given_name,
"family_name": family_name,
"middle_name": middle_name,
"prefix": prefix,
"suffix": suffix,
"nickname": _get(vcard, "nickname"),
"organization": org,
"job_title": _get(vcard, "title"),
"department": None,
"birthday": birthday,
"anniversary": None,
"notes": _get(vcard, "note"),
"photo_base64": None,
"emails": json.dumps(emails, ensure_ascii=False),
"phones": json.dumps(phones, ensure_ascii=False),
"addresses": json.dumps(addresses, ensure_ascii=False),
"urls": json.dumps(urls, ensure_ascii=False),
"social_profiles": json.dumps([], ensure_ascii=False),
"related_names": json.dumps([], ensure_ascii=False),
"categories": json.dumps(categories, ensure_ascii=False),
"raw_vcard": raw_text,
"source": "icloud",
}