diff --git a/README.md b/README.md index ed87ee5..a347fda 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,24 @@ python3 mailer.py aus MariaDB entfernt, ohne Archiv. - Nur iCloud als Quelle, Google/Microsoft sind nicht Teil dieses Repos. +## Kontaktruppen + +iCloud-Länder speichern Gruppen als eigene vCards mit +`X-ADDRESSBOOKSERVER-KIND:group`. Diese werden beim Sync automatisch +erkannt und separat in den Tabellen `groups` und `group_members` +gespeichert (nicht als Kontakte). + +Gruppen sind über die API abrufbar: + +- `GET /api/groups` — Alle Gruppen mit Member-Anzahl +- `GET /api/groups/{id}` — Gruppe mit aufgelösten Members +- `GET /api/groups/{id}/members` — Nur Members einer Gruppe +- `GET /api/contacts/{id}` — Enthält `groups`-Feld mit Gruppennamen + +Wird ein Kontakt gelöscht, wird die Mitgliedschaft in Gruppen +automatisch entfernt (`ON DELETE CASCADE`). Die Gruppe selbst bleibt +erhalten. + ## 11. Web-Ansicht und API (interner Zugriff über Authelia) Läuft als zweiter Service aus demselben Image, aber mit anderem @@ -206,13 +224,16 @@ Zugriff ohne den Reverse-Proxy ist damit nicht möglich. |---------|------|--------------| | `GET` | `/` | Dashboard mit Kontaktdaten-Übersicht, letzten Sync-Status und Geburtstagen der nächsten 7 Tage (HTML) | | `GET` | `/search` | Web-UI -- Suchfunktion, zeigt Kontakte des eingeloggten Users | -| `GET` | `/contacts/{id}` | Web-UI -- Detailseite eines einzelnen Kontakts | +| `GET` | `/contacts/{id}` | Web-UI -- Detailseite eines einzelnen Kontakts (inkl. Gruppen) | | `GET` | `/api/health` | Health Check (`{"status": "ok"}`), kein Login nötig | | `GET` | `/api/contacts` | Kontaktsuche mit Pagination (`?q=...&limit=...&offset=...`) | -| `GET` | `/api/contacts/{id}` | Einzelnen Kontakt per ID abrufen | +| `GET` | `/api/contacts/{id}` | Einzelnen Kontakt per ID abrufen (inkl. `groups`-Feld) | | `GET` | `/api/contacts/count` | Anzahl der Kontakte des eingeloggten Users | | `GET` | `/api/contacts/birthdays/today` | Heutige Geburtstage | | `GET` | `/api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (`?days=7`, Default 7) | +| `GET` | `/api/groups` | Gruppenliste mit Member-Anzahl | +| `GET` | `/api/groups/{id}` | Einzelne Gruppe mit aufgelösten Members | +| `GET` | `/api/groups/{id}/members` | Members einer Gruppe (Kontaktdaten) | | `GET` | `/api/sync-runs` | Letzte 50 Sync-Runs (Status, Zeitstempel, Fehler) | Alle Endpunkte (außer `/api/health`) erfordern eine Authentifizierung diff --git a/SPEC.md b/SPEC.md index a89a4c8..d211fbf 100644 --- a/SPEC.md +++ b/SPEC.md @@ -92,6 +92,11 @@ Siehe `sql/schema.sql`. Wichtigste Änderungen gegenüber v1: - Neue Tabelle `birthday_mail_log`: ein Datensatz pro Tag, an dem erfolgreich eine Geburtstagsmail versendet wurde, verhindert Doppelversand bei mehrfachem Container-Neustart am selben Tag. +- Neue Tabellen `groups` und `group_members`: Speichert + iCloud-Kontaktgruppen (vCards mit `X-ADDRESSBOOKSERVER-KIND:group`) + und deren Mitgliedschaften. Gruppen werden beim Sync erkannt und + nicht als Kontakte in die `contacts`-Tabelle geschrieben. + `group_members` referenziert `groups(id)` mit `ON DELETE CASCADE`. ## 6. Geburtstags-Mailer @@ -247,10 +252,13 @@ geteilt wird. Getrennt ist nur die **Rolle**, in der der Container läuft. | `GET /contacts/{id}` | HTML-Detailseite eines einzelnen Kontakts (Jinja2-Template) | | `GET /api/health` | Health-Check ohne Auth-Anforderung | | `GET /api/contacts` | Kontaktliste, Filter `q` (Freitext), Pagination `limit`/`offset` | -| `GET /api/contacts/{id}` | Einzelner Kontakt (JSON) | +| `GET /api/contacts/{id}` | Einzelner Kontakt (JSON), inklusive `groups`-Feld mit zugehörigen Gruppennamen | | `GET /api/contacts/count` | Anzahl der Kontakte des zugeordneten Accounts | | `GET /api/contacts/birthdays/today` | Heutige Geburtstage (kontospezifisch bzw. global für Admins) | | `GET /api/contacts/birthdays/upcoming` | Geburtstage der nächsten N Tage (Parameter `days`, Default 7) | +| `GET /api/groups` | Gruppenliste mit `member_count`, Pagination `limit`/`offset` | +| `GET /api/groups/{id}` | Einzelne Gruppe mit aufgelösten Members (Name + UID) | +| `GET /api/groups/{id}/members` | Nur Members einer Gruppe (Kontaktdaten aufgelöst) | | `GET /api/sync-runs` | Sync-Historie (kontospezifisch bzw. global für Admins) | ### 12.5 Netzwerkkontext diff --git a/sql/schema.sql b/sql/schema.sql index a82249f..b92e134 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -57,6 +57,29 @@ CREATE TABLE IF NOT EXISTS sync_runs ( error_message TEXT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- iCloud-Kontaktgruppen (vCards mit X-ADDRESSBOOKSERVER-KIND:group) +CREATE TABLE IF NOT EXISTS `groups` ( + id INT AUTO_INCREMENT PRIMARY KEY, + account VARCHAR(100) NOT NULL, + uid VARCHAR(255) NOT NULL, + etag VARCHAR(255) NULL, + name VARCHAR(512) NULL, + raw_vcard LONGTEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + last_synced_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + sync_run_id VARCHAR(64) NULL, + UNIQUE KEY uq_groups_account_uid (account, uid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS group_members ( + id INT AUTO_INCREMENT PRIMARY KEY, + group_id INT NOT NULL, + member_uid VARCHAR(255) NOT NULL, + FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE, + UNIQUE KEY uq_group_member (group_id, member_uid) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Protokoll der Geburtstags-Mails, verhindert Doppelversand am selben Tag. CREATE TABLE IF NOT EXISTS birthday_mail_log ( id INT AUTO_INCREMENT PRIMARY KEY, diff --git a/src/api/main.py b/src/api/main.py index 16b1fee..a0c6d25 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -21,7 +21,13 @@ from starlette.middleware.sessions import SessionMiddleware import db from api.auth import get_current_user, resolve_account_for_user -from api.schemas import ContactListResponse, ContactOut, SyncRunOut +from api.schemas import ( + ContactListResponse, + ContactOut, + GroupDetailOut, + GroupListResponse, + SyncRunOut, +) from config import Config from mailer import build_message, fetch_birthdays_for_date, send_message from utils import fmt_birthday_age, fmt_birthday_short, is_unknown_year @@ -48,7 +54,7 @@ def _fmt_ts(dt) -> str | None: return dt.astimezone(Config.TIMEZONE).strftime("%d.%m.%Y %H:%M:%S") -def _row_to_contact_out(row: dict) -> dict: +def _row_to_contact_out(row: dict, group_names: list[str] | None = None) -> dict: row = dict(row) for field in ["emails", "phones", "addresses", "urls", "social_profiles", "categories"]: raw = row.get(field) @@ -56,6 +62,7 @@ def _row_to_contact_out(row: dict) -> dict: if not row.get("full_name"): row["full_name"] = db._build_full_name(row) row["updated_at"] = _fmt_ts(row["updated_at"]) + row["groups"] = group_names if group_names is not None else [] return row @@ -133,9 +140,13 @@ def get_contact(contact_id: int, current_user: str = Depends(get_current_user)): ) row = cur.fetchone() - if not row: - return {} - return _row_to_contact_out(row) + if not row: + return {} + + groups = db.get_groups_for_contact(conn, row["account"], row["uid"]) + group_names = [g["name"] for g in groups if g.get("name")] + + return _row_to_contact_out(row, group_names=group_names) @app.get("/api/contacts/birthdays/today", response_model=list[ContactOut]) @@ -202,6 +213,108 @@ def list_sync_runs(current_user: str = Depends(get_current_user)): return rows +@app.get("/api/groups", response_model=GroupListResponse) +def list_groups( + request: Request, + 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) + with conn.cursor() as cur: + cur.execute(f"SELECT COUNT(*) AS total FROM `groups` {where_clause}", params) + total = cur.fetchone()["total"] + + cur.execute( + f"""SELECT g.id, g.account, g.uid, g.name, g.updated_at, + (SELECT COUNT(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count + FROM `groups` g {where_clause} + ORDER BY g.name + LIMIT %s OFFSET %s""", + params + [limit, offset], + ) + rows = cur.fetchall() + + for r in rows: + r["updated_at"] = _fmt_ts(r["updated_at"]) + return {"total": total, "items": rows} + + +@app.get("/api/groups/{group_id}", response_model=GroupDetailOut) +def get_group(group_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 g.id = %s" if where_clause else "WHERE g.id = %s" + with conn.cursor() as cur: + cur.execute( + f"""SELECT g.id, g.account, g.uid, g.name, g.updated_at, + (SELECT COUNT(*) FROM group_members gm WHERE gm.group_id = g.id) AS member_count + FROM `groups` g {where_clause} {id_clause}""", + params + [group_id], + ) + group_row = cur.fetchone() + + if not group_row: + return {} + + cur.execute( + """SELECT gm.member_uid, c.id, c.full_name, c.given_name, c.family_name + FROM group_members gm + LEFT JOIN contacts c ON c.account = g.account AND c.uid = gm.member_uid + CROSS JOIN `groups` g + WHERE g.id = %s AND gm.group_id = g.id""", + (group_id,), + ) + members = cur.fetchall() + + for m in members: + if not m.get("full_name"): + m["full_name"] = db._build_full_name(m) if any(m.get(k) for k in ("given_name", "family_name")) else None + + group_row["updated_at"] = _fmt_ts(group_row["updated_at"]) + group_row["members"] = [ + {"member_uid": m["member_uid"], "full_name": m["full_name"], "id": m["id"]} + for m in members + ] + return group_row + + +@app.get("/api/groups/{group_id}/members") +def get_group_members(group_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) + with conn.cursor() as cur: + cur.execute( + f"""SELECT g.id FROM `groups` g {where_clause} + {"AND" if where_clause else "WHERE"} g.id = %s""", + params + [group_id], + ) + if not cur.fetchone(): + return {} + + cur.execute( + """SELECT gm.member_uid, c.id, c.full_name, c.given_name, c.family_name, + c.organization, c.birthday, c.photo_url + FROM group_members gm + LEFT JOIN contacts c ON c.uid = gm.member_uid + WHERE gm.group_id = %s""", + (group_id,), + ) + rows = cur.fetchall() + + for r in rows: + if not r.get("full_name"): + r["full_name"] = db._build_full_name(r) if any(r.get(k) for k in ("given_name", "family_name")) else None + return {"group_id": group_id, "members": rows} + + @app.get("/", response_class=HTMLResponse) def web_dashboard( request: Request, @@ -462,11 +575,14 @@ def web_contact( ) row = cur.fetchone() - if not row: - from fastapi.responses import RedirectResponse - return RedirectResponse(url="/search", status_code=303) + if not row: + from fastapi.responses import RedirectResponse + return RedirectResponse(url="/search", status_code=303) - contact = _row_to_contact_out(row) + groups = db.get_groups_for_contact(conn, row["account"], row["uid"]) + group_names = [g["name"] for g in groups if g.get("name")] + + contact = _row_to_contact_out(row, group_names=group_names) homecity = "" workcity = "" diff --git a/src/api/schemas.py b/src/api/schemas.py index b739658..2bf036c 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -23,6 +23,7 @@ class ContactOut(BaseModel): urls: list social_profiles: list categories: list + groups: list[str] = [] updated_at: str class Config: @@ -44,3 +45,30 @@ class SyncRunOut(BaseModel): contacts_upserted: int | None contacts_deleted: int | None error_message: str | None + + +class GroupMemberOut(BaseModel): + member_uid: str + full_name: str | None + id: int | None = None + + +class GroupOut(BaseModel): + id: int + account: str + uid: str + name: str | None + member_count: int + updated_at: str + + class Config: + from_attributes = True + + +class GroupDetailOut(GroupOut): + members: list[GroupMemberOut] + + +class GroupListResponse(BaseModel): + total: int + items: list[GroupOut] diff --git a/src/api/templates/contact.html b/src/api/templates/contact.html index da6646f..6856f57 100644 --- a/src/api/templates/contact.html +++ b/src/api/templates/contact.html @@ -422,6 +422,17 @@ {% endif %} + {% if contact.groups %} +
+
Gruppen
+
+ {% for group_name in contact.groups %} + {{ group_name }} + {% endfor %} +
+
+ {% endif %} + {% if contact.notes %}
Notizen
diff --git a/src/db.py b/src/db.py index bda05dc..7e9136b 100644 --- a/src/db.py +++ b/src/db.py @@ -279,3 +279,81 @@ def replace_all_contacts_for_account(conn, account: str, contacts: list[dict], r raise conn.commit() logger.info("Voller Re-Sync für Account %s abgeschlossen: %d Kontakte", account, len(contacts)) + + +def upsert_groups(conn, groups: list[dict], run_id: str): + if not groups: + return + now = datetime.now(timezone.utc) + with conn.cursor() as cur: + for g in groups: + g["sync_run_id"] = run_id + g["last_synced_at"] = now + member_uids = g.pop("member_uids", []) + cols = [k for k in g if k != "member_uids"] + 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 `groups` ({', '.join(cols)}) VALUES ({placeholders}) " + f"ON DUPLICATE KEY UPDATE {update_clause}" + ) + try: + cur.execute(sql, [g[c] for c in cols]) + except Exception as exc: + logger.error("INSERT Gruppe fehlgeschlagen für UID %s: %s", g.get("uid"), exc) + raise + cur.execute("SELECT id FROM `groups` WHERE account = %s AND uid = %s", (g["account"], g["uid"])) + row = cur.fetchone() + if not row: + logger.error("Konnte Gruppen-ID nicht ermitteln für UID %s", g.get("uid")) + continue + group_id = row["id"] + cur.execute("DELETE FROM group_members WHERE group_id = %s", (group_id,)) + if member_uids: + member_values = [(group_id, uid) for uid in member_uids] + cur.executemany( + "INSERT INTO group_members (group_id, member_uid) VALUES (%s, %s)", + member_values, + ) + conn.commit() + + +def delete_groups_by_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 `groups` WHERE account = %s AND uid IN ({placeholders})", + [account] + uids, + ) + conn.commit() + + +def replace_all_groups_for_account(conn, account: str, groups: list[dict], run_id: str): + with conn.cursor() as cur: + cur.execute("DELETE FROM `groups` WHERE account = %s", (account,)) + conn.commit() + if groups: + upsert_groups(conn, groups, run_id) + logger.info("Gruppen-Re-Sync für Account %s abgeschlossen: %d Gruppen", account, len(groups)) + + +def get_groups_for_contact(conn, account: str, member_uid: str) -> list[dict]: + with conn.cursor() as cur: + cur.execute( + """SELECT g.id, g.name, g.uid + FROM `groups` g + JOIN group_members gm ON gm.group_id = g.id + WHERE g.account = %s AND gm.member_uid = %s + ORDER BY g.name""", + (account, member_uid), + ) + return cur.fetchall() + + +def get_group_count(conn, account: str | None) -> int: + where_clause, params = _account_filter_clause(account) + with conn.cursor() as cur: + cur.execute(f"SELECT COUNT(*) AS total FROM `groups` {where_clause}", params) + return cur.fetchone()["total"] diff --git a/src/sync.py b/src/sync.py index 2bc7675..00e7cdd 100644 --- a/src/sync.py +++ b/src/sync.py @@ -12,12 +12,30 @@ import sys import db from carddav_client import ICLOUD_BASE_URL, CardDAVClient, SyncTokenInvalid from config import Config -from vcard_parser import parse_vcard +from vcard_parser import is_group_vcard, parse_group, parse_vcard logging.basicConfig(level=Config.LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("sync") +def _classify_vcards(raw_vcards, raw_etags, account_name): + contacts, groups = [], [] + for v, etag in zip(raw_vcards, raw_etags): + if is_group_vcard(v): + g = parse_group(v, account_name, etag=etag) + if g: + groups.append(g) + else: + logger.warning("[%s] Gruppen-vCard konnte nicht geparst werden, überspringe", account_name) + else: + c = parse_vcard(v, account_name, etag=etag) + if c: + contacts.append(c) + else: + logger.warning("[%s] vCard konnte nicht geparst werden, überspringe", account_name) + return contacts, groups + + 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() @@ -30,19 +48,14 @@ def sync_account(conn, account, href_to_uid_cache: dict): if not stored_token: logger.info("[%s] Kein sync-token vorhanden, führe initialen Full-Sync aus", account.name) raw_vcards, raw_etags = client.fetch_all_vcards(collection_url) - contacts = [] - for v, etag in zip(raw_vcards, raw_etags): - c = parse_vcard(v, account.name, etag=etag) - if c: - contacts.append(c) - else: - logger.warning("[%s] vCard konnte nicht geparst werden, überspringe", account.name) + contacts, groups = _classify_vcards(raw_vcards, raw_etags, account.name) db.replace_all_contacts_for_account(conn, account.name, contacts, run_id) + db.replace_all_groups_for_account(conn, account.name, groups, run_id) _, _, _, new_token = client.sync_collection(collection_url, None, fetch_missing=False) 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)) + logger.info("[%s] Initialer Sync abgeschlossen: %d Kontakte, %d Gruppen", account.name, len(contacts), len(groups)) return try: @@ -51,29 +64,19 @@ def sync_account(conn, account, href_to_uid_cache: dict): 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, raw_etags = client.fetch_all_vcards(collection_url) - contacts = [] - for v, etag in zip(raw_vcards, raw_etags): - c = parse_vcard(v, account.name, etag=etag) - if c: - contacts.append(c) - else: - logger.warning("[%s] vCard konnte nicht geparst werden, überspringe", account.name) + contacts, groups = _classify_vcards(raw_vcards, raw_etags, account.name) db.replace_all_contacts_for_account(conn, account.name, contacts, run_id) + db.replace_all_groups_for_account(conn, account.name, groups, run_id) _, _, _, new_token = client.sync_collection(collection_url, None, fetch_missing=False) 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)) + logger.info("[%s] Re-Sync abgeschlossen: %d Kontakte, %d Gruppen", account.name, len(contacts), len(groups)) return - contacts = [] - for v, etag in zip(changed_vcards, etags): - c = parse_vcard(v, account.name, etag=etag) - if c: - contacts.append(c) - else: - logger.warning("[%s] vCard konnte nicht geparst werden, überspringe", account.name) + contacts, groups = _classify_vcards(changed_vcards, etags, account.name) db.upsert_contacts(conn, contacts, run_id) + db.upsert_groups(conn, groups, run_id) deleted_uids = [] for href in deleted_hrefs: @@ -83,14 +86,15 @@ def sync_account(conn, account, href_to_uid_cache: dict): else: logger.warning("[%s] Konnte UID nicht aus href extrahieren: %s", account.name, href) db.delete_contacts_by_href_uids(conn, account.name, deleted_uids) + db.delete_groups_by_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), + "[%s] Delta-Sync abgeschlossen: %d geändert/neu, %d gelöscht (%d Gruppen geändert)", + account.name, len(contacts), len(deleted_uids), len(groups), ) except Exception as exc: logger.exception("[%s] Sync-Lauf %s fehlgeschlagen", account.name, run_id) diff --git a/src/vcard_parser.py b/src/vcard_parser.py index 7ede460..03fe680 100644 --- a/src/vcard_parser.py +++ b/src/vcard_parser.py @@ -33,6 +33,48 @@ def _type_str(obj) -> str: return "other" +_GROUP_MARKER = "X-ADDRESSBOOKSERVER-KIND:group" +_MEMBER_PREFIX = "urn:uuid:" + + +def is_group_vcard(raw_text: str) -> bool: + return _GROUP_MARKER in raw_text + + +def parse_group(raw_text: str, account: str, etag: str | None = None) -> dict | None: + try: + vcard = vobject.readOne(raw_text) + except Exception as exc: + logger.warning("Gruppen-vCard konnte nicht geparst werden: %s", exc) + return None + + uid = _get(vcard, "uid") + if not uid: + logger.warning("Gruppen-vCard ohne UID übersprungen") + return None + + member_uids = [] + for member in vcard.contents.get("x-addressbookserver-member", []): + value = member.value if hasattr(member, "value") else str(member) + if value.startswith(_MEMBER_PREFIX): + member_uids.append(value[len(_MEMBER_PREFIX):]) + else: + member_uids.append(value) + + if not member_uids: + logger.debug("Gruppe %s hat keine Members, überspringe", uid) + return None + + return { + "account": account, + "uid": uid, + "etag": etag, + "name": _scalar(_get(vcard, "fn")), + "raw_vcard": raw_text, + "member_uids": member_uids, + } + + def parse_vcard(raw_text: str, account: str, etag: str | None = None) -> dict | None: try: vcard = vobject.readOne(raw_text)