mirror of
https://github.com/skoelle/icloud-contacts-sync.git
synced 2026-09-17 23:40:24 +00:00
initial commit
This commit is contained in:
@@ -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
@@ -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,
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user