from __future__ import annotations import copy from collections.abc import Iterable from dataclasses import dataclass, field from datetime import UTC, datetime import hashlib import json import os import re import unicodedata import urllib.parse from typing import Any from sqlalchemy import and_, exists, false, func, or_ from sqlalchemy.orm import Session, selectinload from govoplan_core.auth import ApiPrincipal from govoplan_core.audit.logging import audit_event from govoplan_core.core.change_sequence import record_change from govoplan_core.db.base import utcnow from govoplan_core.security.credential_envelopes import ( CredentialAccessContext, CredentialEnvelopeError, ResolvedCredentialEnvelope, credential_envelope_summary, list_credential_envelopes, resolve_credential_envelope, ) from govoplan_core.security.secrets import decrypt_secret, encrypt_secret from govoplan_addresses.backend.carddav import ( AddressCardDAVAddressBook, AddressCardDAVClient, AddressCardDAVError, AddressCardDAVObject, AddressCardDAVPreconditionFailed, AddressCardDAVReportResult, AddressCardDAVSyncUnsupported, ensure_collection_url, ) from govoplan_addresses.backend.ldap import ( AddressLdapClient, AddressLdapError, ) from govoplan_addresses.backend.ldap_schemas import ( AddressLdapConnectionRequest, AddressLdapSourceCreateRequest, ) from govoplan_addresses.backend.db.models import ( AddressBook, AddressList, AddressListEntry, AddressSyncConflict, AddressSyncDiagnostic, AddressSyncSource, AddressSyncTombstone, Contact, ContactChannelRule, ContactEmail, ContactFieldProvenance, ContactMergeRecord, ContactPhone, ContactPointQualityDecision, ContactPostalAddress, ContactRedirect, new_uuid, ) from govoplan_addresses.backend.schemas import ( AddressBookCreateRequest, AddressBookUpdateRequest, AddressListCreateRequest, AddressListEntryCreateRequest, AddressListUpdateRequest, AddressCardDavDiscoveryRequest, AddressCardDavSourceCreateRequest, AddressSyncPlanStats, AddressSyncAttemptFinishRequest, AddressSyncConflictCreateRequest, AddressSyncConflictResolveRequest, AddressSyncDiagnosticCreateRequest, AddressSyncSourceCreateRequest, AddressSyncSourceUpdateRequest, AddressSyncTombstoneCreateRequest, ContactCreateRequest, ContactEmailPayload, ContactPhonePayload, ContactPostalAddressPayload, ContactUpdateRequest, ContactChannelRuleCreateRequest, ContactMergeRequest, ContactMergeRecoveryRequest, ContactPointQualityDecisionCreateRequest, ) from govoplan_addresses.backend.vcard import ParsedVCard, ParsedVCardIssue, contacts_to_vcard, parse_vcards_with_issues class AddressBookError(ValueError): pass CORE_CREDENTIAL_ENVELOPE_PREFIX = "credential-envelope:" def address_credential_context( *, tenant_id: str, source_id: str | None = None, ) -> CredentialAccessContext: return CredentialAccessContext( tenant_id=tenant_id, target_scope_type="tenant", target_scope_id=tenant_id, module_id="addresses", server_ref=f"addresses:{source_id}" if source_id else None, ) def available_address_credentials( session: Session, principal: ApiPrincipal, *, source_id: str | None = None, ) -> list[dict[str, Any]]: context = address_credential_context( tenant_id=principal.tenant_id, source_id=source_id, ) return [ credential_envelope_summary(row) for row in list_credential_envelopes(session, context=context) ] @dataclass(frozen=True, slots=True) class VCardImportResult: contacts: list[Contact] issues: list[ParsedVCardIssue] skipped: int @dataclass(slots=True) class AddressSyncPlanItem: action: str href: str | None = None remote_uid: str | None = None contact_id: str | None = None display_name: str | None = None etag: str | None = None message: str | None = None raw_vcard: str | None = None parsed_payload: ContactCreateRequest | None = None source_revision: str | None = None raw_payload: str | None = None source_details: dict[str, Any] = field(default_factory=dict) @dataclass(slots=True) class AddressSyncPlan: sync_source: AddressSyncSource stats: AddressSyncPlanStats items: list[AddressSyncPlanItem] = field(default_factory=list) @dataclass(frozen=True, slots=True) class ContactDuplicateFeature: code: str label: str weight: int value: str @dataclass(frozen=True, slots=True) class ContactDuplicateSuggestion: left: Contact right: Contact score: int confidence: str features: tuple[ContactDuplicateFeature, ...] @dataclass(frozen=True, slots=True) class ContactDuplicateScan: suggestions: tuple[ContactDuplicateSuggestion, ...] scanned_contacts: int candidate_pairs: int truncated: bool @dataclass(frozen=True, slots=True) class ContactRedirectResolution: requested_contact_id: str resolved_contact_id: str redirected: bool redirect_chain: tuple[str, ...] merge_record_ids: tuple[str, ...] @dataclass(frozen=True, slots=True) class AddressQualityCorrection: contact_id: str display_name: str channel: str contact_point_id: str | None state: str reason_code: str reason: str | None effective_from: datetime @dataclass(frozen=True, slots=True) class AddressQualitySummary: contact_count: int contact_point_count: int quality_counts: dict[str, int] duplicate_suggestion_count: int correction_count: int corrections: tuple[AddressQualityCorrection, ...] truncated: bool READ_ONLY_SYNC_DIRECTIONS = {"read_only", "import"} OUTBOUND_SYNC_DIRECTIONS = {"export", "two_way"} INBOUND_SYNC_DIRECTIONS = {"read_only", "import", "two_way"} REMOTE_SYNC_ACTIONS = {"remote_create", "remote_update", "remote_delete"} ADDRESS_MODULE_ID = "addresses" ADDRESS_CONTACTS_COLLECTION = "addresses.contacts" ADDRESS_CONTACT_RESOURCE = "address_contact" CARDDAV_SECRET_ENV_PREFIX = "env:" # noqa: S105 # nosec B105 - reference prefix, not a credential. def _trim(value: str | None) -> str | None: if value is None: return None trimmed = value.strip() return trimmed or None def _account_id(principal: ApiPrincipal) -> str: return principal.account_id def _tenant_id_or_none(principal: ApiPrincipal) -> str | None: try: return principal.tenant_id except RuntimeError: return None def _visible_book_predicate(principal: ApiPrincipal): tenant_id = _tenant_id_or_none(principal) account_id = _account_id(principal) predicates = [AddressBook.scope_type == "system"] if tenant_id: predicates.extend( [ and_(AddressBook.tenant_id == tenant_id, AddressBook.scope_type == "tenant"), and_(AddressBook.tenant_id == tenant_id, AddressBook.scope_type == "user", AddressBook.scope_id == account_id), ] ) group_ids = tuple(principal.group_ids) if group_ids: predicates.append(and_(AddressBook.tenant_id == tenant_id, AddressBook.scope_type == "group", AddressBook.scope_id.in_(group_ids))) return or_(*predicates) def _visible_books_query(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False): query = session.query(AddressBook).filter(_visible_book_predicate(principal)) if not include_deleted: query = query.filter(AddressBook.deleted_at.is_(None)) return query def _require_mutable_book(book: AddressBook) -> None: if book.read_only: raise AddressBookError("Address book is read-only.") if book.deleted_at is not None: raise AddressBookError("Address book is deleted.") def _require_mutable_address_list(address_list: AddressList) -> None: _require_mutable_book(address_list.address_book) if address_list.read_only: raise AddressBookError("Address list is read-only.") if address_list.deleted_at is not None: raise AddressBookError("Address list is deleted.") def _scope_id_for_create(principal: ApiPrincipal, payload: AddressBookCreateRequest, *, allow_system: bool = False) -> tuple[str | None, str | None]: tenant_id = _tenant_id_or_none(principal) if payload.scope_type == "system": if not allow_system: raise AddressBookError("Creating system address books requires address book administration permission.") return None, None if not tenant_id: raise AddressBookError("A tenant context is required for tenant, group, and user address books.") if payload.scope_type == "tenant": return tenant_id, tenant_id if payload.scope_type == "user": return tenant_id, _account_id(principal) if payload.scope_type == "group": group_id = _trim(payload.group_id) if not group_id: raise AddressBookError("Group address books require a group id.") if group_id not in principal.group_ids and not principal.has("addresses:address_book:admin"): raise AddressBookError("The selected group is not visible to the current principal.") return tenant_id, group_id raise AddressBookError("Unsupported address book scope.") def list_address_books(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False) -> list[AddressBook]: return _visible_books_query(session, principal, include_deleted=include_deleted).order_by(AddressBook.scope_type.asc(), AddressBook.name.asc(), AddressBook.id.asc()).all() def address_book_contact_counts(session: Session, book_ids: Iterable[str], *, include_deleted: bool = False) -> dict[str, int]: ids = tuple(book_ids) if not ids: return {} query = ( session.query(Contact.address_book_id, func.count(Contact.id)) .filter(Contact.address_book_id.in_(ids)) .group_by(Contact.address_book_id) ) if not include_deleted: query = query.filter(Contact.deleted_at.is_(None)) rows = query.all() return {book_id: count for book_id, count in rows} def address_list_entry_counts(session: Session, address_list_ids: Iterable[str]) -> dict[str, int]: ids = tuple(address_list_ids) if not ids: return {} rows = ( session.query(AddressListEntry.address_list_id, func.count(AddressListEntry.id)) .join(Contact, AddressListEntry.contact_id == Contact.id) .filter(AddressListEntry.address_list_id.in_(ids), Contact.deleted_at.is_(None)) .group_by(AddressListEntry.address_list_id) .all() ) return {address_list_id: count for address_list_id, count in rows} def get_visible_address_book(session: Session, principal: ApiPrincipal, book_id: str, *, include_deleted: bool = False) -> AddressBook: book = _visible_books_query(session, principal, include_deleted=include_deleted).filter(AddressBook.id == book_id).one_or_none() if book is None: raise AddressBookError("Address book not found.") return book def _visible_sync_source_query(session: Session, principal: ApiPrincipal): book_ids = [book.id for book in list_address_books(session, principal)] if not book_ids: return session.query(AddressSyncSource).filter(false()) return session.query(AddressSyncSource).filter(AddressSyncSource.address_book_id.in_(book_ids)) def list_sync_sources( session: Session, principal: ApiPrincipal, *, address_book_id: str | None = None, include_disabled: bool = False, ) -> list[AddressSyncSource]: query = _visible_sync_source_query(session, principal) if address_book_id: get_visible_address_book(session, principal, address_book_id) query = query.filter(AddressSyncSource.address_book_id == address_book_id) if not include_disabled: query = query.filter(AddressSyncSource.enabled.is_(True)) return query.order_by(AddressSyncSource.display_name.asc(), AddressSyncSource.id.asc()).all() def get_visible_sync_source(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource: sync_source = _visible_sync_source_query(session, principal).filter(AddressSyncSource.id == sync_source_id).one_or_none() if sync_source is None: raise AddressBookError("Address sync source not found.") return sync_source def create_sync_source( session: Session, principal: ApiPrincipal, address_book_id: str, payload: AddressSyncSourceCreateRequest, *, trusted_connector_metadata: bool = False, ) -> AddressSyncSource: book = get_visible_address_book(session, principal, address_book_id) if book.deleted_at is not None: raise AddressBookError("Address book is deleted.") connector_type = _trim(payload.connector_type) display_name = _trim(payload.display_name) if not connector_type: raise AddressBookError("Sync connector type is required.") if not display_name: raise AddressBookError("Sync source display name is required.") if connector_type.casefold() == "carddav" and not trusted_connector_metadata: _assert_api_carddav_metadata_safe(payload.metadata) if connector_type.casefold() in {"ldap", "active_directory"} and not trusted_connector_metadata: _assert_api_ldap_metadata_safe(payload.metadata) read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only) sync_source = AddressSyncSource( tenant_id=book.tenant_id, address_book_id=book.id, connector_type=connector_type, display_name=display_name, external_account_ref=_trim(payload.external_account_ref), external_address_book_ref=_trim(payload.external_address_book_ref), sync_direction=payload.sync_direction, read_only=read_only, enabled=payload.enabled, status="idle" if payload.enabled else "disabled", sync_token=payload.sync_token, etag=_trim(payload.etag), remote_revision=_trim(payload.remote_revision), created_by_account_id=_account_id(principal), updated_by_account_id=_account_id(principal), metadata_=payload.metadata or {}, ) session.add(sync_source) session.flush() _apply_sync_source_to_book(book, sync_source) return sync_source def update_sync_source( session: Session, principal: ApiPrincipal, sync_source_id: str, payload: AddressSyncSourceUpdateRequest, ) -> AddressSyncSource: sync_source = get_visible_sync_source(session, principal, sync_source_id) if "display_name" in payload.model_fields_set: display_name = _trim(payload.display_name) if not display_name: raise AddressBookError("Sync source display name is required.") sync_source.display_name = display_name if "external_account_ref" in payload.model_fields_set: sync_source.external_account_ref = _trim(payload.external_account_ref) if "external_address_book_ref" in payload.model_fields_set: sync_source.external_address_book_ref = _trim(payload.external_address_book_ref) if "sync_direction" in payload.model_fields_set and payload.sync_direction is not None: sync_source.sync_direction = payload.sync_direction sync_source.read_only = _read_only_from_sync_direction(payload.sync_direction, payload.read_only) elif "read_only" in payload.model_fields_set and payload.read_only is not None: sync_source.read_only = _read_only_from_sync_direction(sync_source.sync_direction, payload.read_only) if "enabled" in payload.model_fields_set and payload.enabled is not None: sync_source.enabled = payload.enabled if not payload.enabled: sync_source.status = "disabled" elif sync_source.status == "disabled": sync_source.status = "idle" if "sync_token" in payload.model_fields_set: sync_source.sync_token = payload.sync_token if "etag" in payload.model_fields_set: sync_source.etag = _trim(payload.etag) if "remote_revision" in payload.model_fields_set: sync_source.remote_revision = _trim(payload.remote_revision) if "metadata" in payload.model_fields_set: metadata = payload.metadata or {} if sync_source.connector_type.casefold() == "carddav": _assert_api_carddav_metadata_safe(metadata) metadata = _merge_server_owned_carddav_metadata(sync_source.metadata_, metadata) elif sync_source.connector_type.casefold() in {"ldap", "active_directory"}: _assert_api_ldap_metadata_safe(metadata) metadata = _merge_server_owned_ldap_metadata(sync_source.metadata_, metadata) sync_source.metadata_ = metadata sync_source.updated_by_account_id = _account_id(principal) _apply_sync_source_to_book(sync_source.address_book, sync_source) return sync_source def delete_sync_source(session: Session, principal: ApiPrincipal, sync_source_id: str) -> None: sync_source = get_visible_sync_source(session, principal, sync_source_id) book = sync_source.address_book if book.source_ref == sync_source.id: book.source_kind = "local" book.source_ref = None book.read_only = False book.sync_status = None book.sync_error = None book.updated_by_account_id = _account_id(principal) _audit_sync_credential_deletion(session, principal, sync_source) session.delete(sync_source) def _audit_sync_credential_deletion( session: Session, principal: ApiPrincipal, sync_source: AddressSyncSource, ) -> None: """Audit removal of source-owned credential material in the DB transaction. CardDAV credentials are encrypted inside the sync-source row. Deleting that row therefore deletes the credential atomically with the connector. Legacy credential references are detached from the source, but are never resolved or sent to an external provider because ownership cannot be proven. """ tenant_id = _tenant_id_or_none(principal) user = getattr(principal, "user", None) _record_sync_credential_deletion_audit( session, sync_source=sync_source, tenant_id=tenant_id, user_id=getattr(user, "id", None), api_key_id=getattr(principal, "api_key_id", None), deletion_reason="sync_source_deleted", ) def _record_sync_credential_deletion_audit( session: Session, *, sync_source: AddressSyncSource, tenant_id: str | None, user_id: str | None, api_key_id: str | None, deletion_reason: str, ) -> bool: auth = _carddav_auth_metadata(sync_source.metadata_) if auth.get("secret_encrypted"): storage_backend = "encrypted_database" elif auth.get("credential_ref"): storage_backend = "legacy_reference" else: return False audit_event( session, tenant_id=tenant_id, user_id=user_id, api_key_id=api_key_id, action="addresses.sync_credential_deleted", scope="tenant" if tenant_id is not None else "system", object_type="address_sync_credential", object_id=sync_source.id, details={ "sync_source_id": sync_source.id, "connector_type": sync_source.connector_type, "storage_backend": storage_backend, "deletion_reason": deletion_reason, }, ) return True def audit_address_credentials_for_retirement(session: Session) -> int: """Audit credentials that the ensuing destructive table drop deletes.""" sources = session.query(AddressSyncSource).order_by(AddressSyncSource.id.asc()).all() audited = 0 for source in sources: if _record_sync_credential_deletion_audit( session, sync_source=source, tenant_id=source.tenant_id, user_id=None, api_key_id=None, deletion_reason="module_data_retired", ): audited += 1 session.flush() return audited def start_sync_attempt(session: Session, principal: ApiPrincipal, sync_source_id: str) -> AddressSyncSource: sync_source = get_visible_sync_source(session, principal, sync_source_id) if not sync_source.enabled: raise AddressBookError("Address sync source is disabled.") sync_source.status = "running" sync_source.last_attempted_at = utcnow() sync_source.last_error = None sync_source.updated_by_account_id = _account_id(principal) sync_source.address_book.sync_status = "running" sync_source.address_book.sync_error = None sync_source.address_book.updated_by_account_id = _account_id(principal) return sync_source def finish_sync_attempt( session: Session, principal: ApiPrincipal, sync_source_id: str, payload: AddressSyncAttemptFinishRequest, ) -> AddressSyncSource: sync_source = get_visible_sync_source(session, principal, sync_source_id) sync_source.status = payload.status sync_source.last_attempted_at = sync_source.last_attempted_at or utcnow() sync_source.last_error = _trim(payload.error) sync_source.last_diagnostic = payload.diagnostic or None if payload.status == "succeeded": sync_source.last_success_at = utcnow() sync_source.last_error = None if "sync_token" in payload.model_fields_set: sync_source.sync_token = payload.sync_token if "etag" in payload.model_fields_set: sync_source.etag = _trim(payload.etag) if "remote_revision" in payload.model_fields_set: sync_source.remote_revision = _trim(payload.remote_revision) sync_source.updated_by_account_id = _account_id(principal) sync_source.address_book.sync_status = payload.status sync_source.address_book.sync_error = sync_source.last_error sync_source.address_book.updated_by_account_id = _account_id(principal) return sync_source def list_sync_diagnostics( session: Session, principal: ApiPrincipal, sync_source_id: str, *, limit: int = 100, ) -> list[AddressSyncDiagnostic]: sync_source = get_visible_sync_source(session, principal, sync_source_id) return ( session.query(AddressSyncDiagnostic) .filter(AddressSyncDiagnostic.sync_source_id == sync_source.id) .order_by(AddressSyncDiagnostic.created_at.desc(), AddressSyncDiagnostic.id.desc()) .limit(max(1, min(limit, 500))) .all() ) def record_sync_diagnostic( session: Session, principal: ApiPrincipal, sync_source_id: str, payload: AddressSyncDiagnosticCreateRequest, ) -> AddressSyncDiagnostic: sync_source = get_visible_sync_source(session, principal, sync_source_id) diagnostic = AddressSyncDiagnostic( tenant_id=sync_source.tenant_id, sync_source_id=sync_source.id, severity=payload.severity, code=_trim(payload.code) or "sync", message=_trim(payload.message) or "Sync diagnostic", details=payload.details or {}, ) sync_source.last_diagnostic = { "severity": diagnostic.severity, "code": diagnostic.code, "message": diagnostic.message, } if diagnostic.severity == "error": sync_source.last_error = diagnostic.message sync_source.status = "failed" sync_source.address_book.sync_status = "failed" sync_source.address_book.sync_error = diagnostic.message sync_source.updated_by_account_id = _account_id(principal) session.add(diagnostic) return diagnostic def list_sync_tombstones( session: Session, principal: ApiPrincipal, sync_source_id: str, *, limit: int = 200, ) -> list[AddressSyncTombstone]: sync_source = get_visible_sync_source(session, principal, sync_source_id) return ( session.query(AddressSyncTombstone) .filter(AddressSyncTombstone.sync_source_id == sync_source.id) .order_by(AddressSyncTombstone.created_at.desc(), AddressSyncTombstone.id.desc()) .limit(max(1, min(limit, 1000))) .all() ) def record_sync_tombstone( session: Session, principal: ApiPrincipal, sync_source_id: str, payload: AddressSyncTombstoneCreateRequest, ) -> AddressSyncTombstone: sync_source = get_visible_sync_source(session, principal, sync_source_id) contact = _sync_contact_or_none(session, principal, sync_source, payload.contact_id) if not _trim(payload.remote_uid) and not _trim(payload.resource_href) and contact is None: raise AddressBookError("Sync tombstone requires a contact id, remote uid, or resource href.") tombstone = AddressSyncTombstone( tenant_id=sync_source.tenant_id, sync_source_id=sync_source.id, address_book_id=sync_source.address_book_id, contact_id=contact.id if contact is not None else None, remote_uid=_trim(payload.remote_uid), resource_href=_trim(payload.resource_href), local_deleted_at=payload.local_deleted_at, remote_deleted_at=payload.remote_deleted_at, synced_at=payload.synced_at, metadata_=payload.metadata or {}, ) session.add(tombstone) return tombstone def list_sync_conflicts( session: Session, principal: ApiPrincipal, sync_source_id: str, *, status_filter: str | None = "open", limit: int = 200, ) -> list[AddressSyncConflict]: sync_source = get_visible_sync_source(session, principal, sync_source_id) query = session.query(AddressSyncConflict).filter(AddressSyncConflict.sync_source_id == sync_source.id) if status_filter: query = query.filter(AddressSyncConflict.status == status_filter) return query.order_by(AddressSyncConflict.created_at.desc(), AddressSyncConflict.id.desc()).limit(max(1, min(limit, 1000))).all() def record_sync_conflict( session: Session, principal: ApiPrincipal, sync_source_id: str, payload: AddressSyncConflictCreateRequest, ) -> AddressSyncConflict: sync_source = get_visible_sync_source(session, principal, sync_source_id) contact = _sync_contact_or_none(session, principal, sync_source, payload.contact_id) field_path = _trim(payload.field_path) if not field_path: raise AddressBookError("Sync conflict field path is required.") conflict = AddressSyncConflict( tenant_id=sync_source.tenant_id, sync_source_id=sync_source.id, address_book_id=sync_source.address_book_id, contact_id=contact.id if contact is not None else None, remote_uid=_trim(payload.remote_uid), resource_href=_trim(payload.resource_href), field_path=field_path, local_value=payload.local_value, remote_value=payload.remote_value, local_updated_at=payload.local_updated_at, remote_updated_at=payload.remote_updated_at, status="open", metadata_=payload.metadata or {}, ) sync_source.status = "conflict" sync_source.address_book.sync_status = "conflict" sync_source.updated_by_account_id = _account_id(principal) session.add(conflict) return conflict def resolve_sync_conflict( session: Session, principal: ApiPrincipal, conflict_id: str, payload: AddressSyncConflictResolveRequest, ) -> AddressSyncConflict: conflict = get_visible_sync_conflict(session, principal, conflict_id) if payload.status == "resolved" and payload.resolution == "use_remote": _apply_remote_sync_conflict(session, principal, conflict) elif payload.status == "resolved" and payload.resolution == "merge": if payload.merged_payload is None: raise AddressBookError("Merge resolution requires a merged contact payload.") _apply_payload_sync_conflict(session, principal, conflict, payload.merged_payload) conflict.status = payload.status conflict.resolution = payload.resolution conflict.resolved_at = utcnow() conflict.resolved_by_account_id = _account_id(principal) if not list_sync_conflicts(session, principal, conflict.sync_source_id, status_filter="open", limit=1): conflict.sync_source.status = "idle" conflict.address_book.sync_status = "idle" return conflict def _apply_remote_sync_conflict(session: Session, principal: ApiPrincipal, conflict: AddressSyncConflict) -> None: metadata = dict(conflict.metadata_ or {}) remote_value = dict(conflict.remote_value or {}) raw_payload = metadata.get("remote_payload") or remote_value.get("payload") if not isinstance(raw_payload, dict): raise AddressBookError("This conflict does not include a stored remote payload that can be applied automatically.") payload = ContactCreateRequest.model_validate(raw_payload) _apply_payload_sync_conflict(session, principal, conflict, payload) def _apply_payload_sync_conflict(session: Session, principal: ApiPrincipal, conflict: AddressSyncConflict, payload: ContactCreateRequest) -> None: metadata = dict(conflict.metadata_ or {}) remote_value = dict(conflict.remote_value or {}) item = AddressSyncPlanItem( action="update", href=conflict.resource_href, remote_uid=conflict.remote_uid, contact_id=conflict.contact_id, display_name=payload.display_name, etag=str(remote_value.get("etag") or "") or None, raw_vcard=metadata.get("raw_vcard") if isinstance(metadata.get("raw_vcard"), str) else None, parsed_payload=payload, source_revision=str(metadata.get("source_revision") or remote_value.get("etag") or "") or None, ) _upsert_remote_contact(session, principal, conflict.sync_source, item) def get_visible_sync_conflict(session: Session, principal: ApiPrincipal, conflict_id: str) -> AddressSyncConflict: sync_source_ids = [source.id for source in list_sync_sources(session, principal, include_disabled=True)] if not sync_source_ids: raise AddressBookError("Address sync conflict not found.") conflict = session.query(AddressSyncConflict).filter(AddressSyncConflict.id == conflict_id, AddressSyncConflict.sync_source_id.in_(sync_source_ids)).one_or_none() if conflict is None: raise AddressBookError("Address sync conflict not found.") return conflict def discover_carddav_address_books( session: Session, principal: ApiPrincipal, payload: AddressCardDavDiscoveryRequest, ) -> list[AddressCardDAVAddressBook]: _assert_no_caller_carddav_credential_ref(payload.credential_ref) source = get_visible_sync_source(session, principal, payload.source_id) if payload.source_id else None client = _carddav_client_from_payload(session, principal, payload, source=source) return client.discover_addressbooks() def create_carddav_sync_source( session: Session, principal: ApiPrincipal, address_book_id: str, payload: AddressCardDavSourceCreateRequest, ) -> AddressSyncSource: _assert_no_caller_carddav_credential_ref(payload.credential_ref) if payload.credential_ref and (payload.password is not None or payload.bearer_token is not None): raise AddressBookError("Select a reusable credential or enter a new secret, not both.") collection_url = ensure_collection_url(payload.collection_url) display_name = _trim(payload.display_name) or "CardDAV address book" metadata = _carddav_metadata( auth_type=payload.auth_type, username=payload.username, password=_secret_value(payload.password), bearer_token=_secret_value(payload.bearer_token), credential_ref=payload.credential_ref, collection_url=collection_url, ) source = create_sync_source( session, principal, address_book_id, AddressSyncSourceCreateRequest( connector_type="carddav", display_name=display_name, external_address_book_ref=collection_url, sync_direction=payload.sync_direction, read_only=payload.read_only, sync_token=payload.sync_token, etag=payload.etag, remote_revision=payload.remote_revision, metadata=metadata, ), trusted_connector_metadata=True, ) reusable = _resolve_core_address_credential( session, tenant_id=principal.tenant_id, source_id=source.id, credential_ref=payload.credential_ref, ) if reusable is not None: source_metadata = dict(source.metadata_ or {}) auth = dict(source_metadata.get("carddav") or {}) auth["username"] = _credential_username(reusable) or auth.get("username") source_metadata["carddav"] = auth source.metadata_ = source_metadata return source def discover_ldap_base_dns( session: Session, principal: ApiPrincipal, payload: AddressLdapConnectionRequest, *, client: AddressLdapClient | None = None, ) -> tuple[str, ...]: _assert_reusable_credential_ref(payload.credential_ref) ldap_client = client or _ldap_client_from_connection_payload( session, principal, payload, ) return ldap_client.discover_base_dns() def create_ldap_sync_source( session: Session, principal: ApiPrincipal, address_book_id: str, payload: AddressLdapSourceCreateRequest, ) -> AddressSyncSource: _assert_reusable_credential_ref(payload.credential_ref) # Constructor validation rejects plaintext LDAP and embedded URL credentials # without opening a network connection. AddressLdapClient( url=payload.url, bind_dn=payload.bind_dn, start_tls=payload.start_tls, connect_timeout=payload.connect_timeout, receive_timeout=payload.receive_timeout, ) metadata = { "ldap": { "url": payload.url.strip(), "base_dn": payload.base_dn.strip(), "search_filter": payload.search_filter.strip(), "start_tls": payload.start_tls, "connect_timeout": payload.connect_timeout, "receive_timeout": payload.receive_timeout, "page_size": payload.page_size, "max_entries": payload.max_entries, "attribute_map": dict(payload.attribute_map), "bind_dn": _trim(payload.bind_dn), "credential_ref": _trim(payload.credential_ref), } } source = create_sync_source( session, principal, address_book_id, AddressSyncSourceCreateRequest( connector_type="ldap", display_name=payload.display_name, external_account_ref=payload.url, external_address_book_ref=payload.base_dn, sync_direction="read_only", read_only=True, metadata=metadata, ), trusted_connector_metadata=True, ) reusable = _resolve_core_address_credential( session, tenant_id=principal.tenant_id, source_id=source.id, credential_ref=payload.credential_ref, ) if reusable is not None and not metadata["ldap"].get("bind_dn"): metadata["ldap"]["bind_dn"] = _credential_username(reusable) source.metadata_ = metadata return source def preview_sync_source( session: Session, principal: ApiPrincipal, sync_source_id: str, *, force_full: bool = False, password: str | None = None, bearer_token: str | None = None, client: AddressCardDAVClient | AddressLdapClient | None = None, ) -> AddressSyncPlan: sync_source = get_visible_sync_source(session, principal, sync_source_id) if sync_source.connector_type == "carddav": return _build_carddav_sync_plan( session, principal, sync_source, force_full=force_full, password=password, bearer_token=bearer_token, client=client, # type: ignore[arg-type] ) if sync_source.connector_type in {"ldap", "active_directory"}: return _build_ldap_sync_plan( session, principal, sync_source, client=client, # type: ignore[arg-type] ) raise AddressBookError(f"Preview is not implemented for {sync_source.connector_type} sync sources.") def run_sync_source( session: Session, principal: ApiPrincipal, sync_source_id: str, *, force_full: bool = False, password: str | None = None, bearer_token: str | None = None, client: AddressCardDAVClient | AddressLdapClient | None = None, ) -> AddressSyncPlan: sync_source = start_sync_attempt(session, principal, sync_source_id) write_client = client if sync_source.connector_type == "carddav" and write_client is None: write_client = _carddav_client_for_source(session, sync_source, password=password, bearer_token=bearer_token) try: if sync_source.connector_type == "carddav": plan = _build_carddav_sync_plan( session, principal, sync_source, force_full=force_full, password=password, bearer_token=bearer_token, client=write_client, # type: ignore[arg-type] ) elif sync_source.connector_type in {"ldap", "active_directory"}: plan = _build_ldap_sync_plan( session, principal, sync_source, client=write_client, # type: ignore[arg-type] ) else: raise AddressBookError(f"Sync is not implemented for {sync_source.connector_type} sources.") _apply_address_sync_plan( session, principal, plan, client=write_client if sync_source.connector_type == "carddav" else None, # type: ignore[arg-type] ) status = "conflict" if plan.stats.conflicts else "succeeded" if plan.stats.errors: status = "failed" finish_sync_attempt( session, principal, sync_source.id, AddressSyncAttemptFinishRequest( status=status, sync_token=plan.stats.sync_token, etag=plan.stats.etag, remote_revision=plan.stats.remote_revision, error=f"{plan.stats.errors} sync item errors" if plan.stats.errors else None, diagnostic=_sync_plan_diagnostic(plan), ), ) return plan except Exception as exc: finish_sync_attempt( session, principal, sync_source.id, AddressSyncAttemptFinishRequest(status="failed", error=str(exc), diagnostic={"error": str(exc)}), ) raise def _build_ldap_sync_plan( session: Session, principal: ApiPrincipal, sync_source: AddressSyncSource, *, client: AddressLdapClient | None, ) -> AddressSyncPlan: settings = _ldap_metadata(sync_source.metadata_) if not settings: raise AddressBookError("LDAP sync source configuration is missing.") ldap_client = client or _ldap_client_for_source(session, sync_source) attribute_map = { str(key): str(value) for key, value in dict(settings.get("attribute_map") or {}).items() if str(key).strip() and str(value).strip() } source_key_attribute = attribute_map.get("source_key") if not source_key_attribute: raise AddressBookError("LDAP source mapping requires a stable source_key attribute.") attributes = tuple( dict.fromkeys( [ *attribute_map.values(), "entryUUID", "objectGUID", "modifyTimestamp", "uSNChanged", "entryCSN", ] ) ) try: result = ldap_client.search( base_dn=str(settings.get("base_dn") or sync_source.external_address_book_ref or ""), search_filter=str(settings.get("search_filter") or "(objectClass=person)"), attributes=attributes, page_size=int(settings.get("page_size") or 500), max_entries=int(settings.get("max_entries") or 10_000), ) except AddressLdapError as exc: raise AddressBookError(str(exc)) from exc stats = AddressSyncPlanStats(full_sync=True) plan = AddressSyncPlan(sync_source=sync_source, stats=stats) existing = { str(contact.source_ref): contact for contact in _ldap_contacts_for_source(session, sync_source) if contact.source_ref } observed_refs: set[str] = set() revision_rows: list[dict[str, str]] = [] for entry in result.entries: serialized_attributes = _json_safe_ldap_attributes(entry.attributes) source_key = _ldap_scalar(entry.attributes.get(source_key_attribute)) if not source_key: source_key = entry.dn.strip() if not source_key: _add_sync_plan_item( plan, AddressSyncPlanItem( action="error", message="LDAP entry has neither the configured source key nor a DN.", ), ) continue source_ref = f"ldap:{sync_source.id}:{hashlib.sha256(source_key.encode()).hexdigest()}" if source_ref in observed_refs: _add_sync_plan_item( plan, AddressSyncPlanItem( action="error", href=source_ref, remote_uid=source_key, message="LDAP search returned a duplicate stable source key.", ), ) continue observed_refs.add(source_ref) source_revision = _ldap_source_revision( entry.attributes, attribute_map=attribute_map, serialized_attributes=serialized_attributes, ) revision_rows.append({"source_key": source_key, "revision": source_revision}) try: payload = _ldap_contact_payload( entry.attributes, attribute_map=attribute_map, sync_source=sync_source, source_key=source_key, source_revision=source_revision, dn=entry.dn, ) except (AddressBookError, ValueError) as exc: _add_sync_plan_item( plan, AddressSyncPlanItem( action="error", href=source_ref, remote_uid=source_key, message=str(exc), source_revision=source_revision, ), ) continue local = existing.get(source_ref) action = "create" if local is not None: comparable = payload.model_dump(mode="json", exclude={"provenance"}) action = ( "unchanged" if local.deleted_at is None and _contact_payload_for_conflict(local) == comparable and local.source_revision == source_revision else "update" ) _add_sync_plan_item( plan, AddressSyncPlanItem( action=action, href=source_ref, remote_uid=source_key, contact_id=local.id if local is not None else None, display_name=payload.display_name, parsed_payload=payload, source_revision=source_revision, raw_payload=json.dumps(serialized_attributes, sort_keys=True, ensure_ascii=True), source_details={"dn": entry.dn, "source_key": source_key}, ), ) if result.complete: for source_ref, contact in existing.items(): if source_ref not in observed_refs and contact.deleted_at is None: _add_sync_plan_item( plan, AddressSyncPlanItem( action="delete", href=source_ref, remote_uid=str((contact.provenance or {}).get("ldap", {}).get("source_key") or "") or None, contact_id=contact.id, display_name=contact.display_name, message="LDAP authoritative source no longer contains this contact.", ), ) else: _add_sync_plan_item( plan, AddressSyncPlanItem( action="error", message="LDAP result reached the configured entry limit; absence-based deletes are suppressed.", ), ) stats.remote_revision = hashlib.sha256( json.dumps(sorted(revision_rows, key=lambda item: item["source_key"]), sort_keys=True).encode() ).hexdigest() return plan def _ldap_contact_payload( attributes: dict[str, Any], *, attribute_map: dict[str, str], sync_source: AddressSyncSource, source_key: str, source_revision: str, dn: str, ) -> ContactCreateRequest: def scalar(target: str) -> str | None: attribute = attribute_map.get(target) return _ldap_scalar(attributes.get(attribute)) if attribute else None given_name = scalar("given_name") family_name = scalar("family_name") email = scalar("email") organization = scalar("organization") display_name = scalar("display_name") or " ".join( value for value in (given_name, family_name) if value ) or email or organization if not display_name: raise AddressBookError(f'LDAP entry "{dn or source_key}" has no mapped contact identity.') phone = scalar("phone") postal = { target: scalar(target) for target in ("street", "postal_code", "locality", "region", "country") } tag_attribute = attribute_map.get("tags") tags = [str(item).strip() for item in _ldap_values(attributes.get(tag_attribute)) if str(item).strip()] if tag_attribute else [] return ContactCreateRequest( display_name=display_name, given_name=given_name, family_name=family_name, organization=organization, role_title=scalar("role_title"), note=scalar("note"), tags=tags, emails=[ContactEmailPayload(email=email, is_primary=True)] if email else [], phones=[ContactPhonePayload(phone=phone, is_primary=True)] if phone else [], postal_addresses=[ContactPostalAddressPayload(**postal, is_primary=True)] if any(postal.values()) else [], provenance={ "ldap": { "sync_source_id": sync_source.id, "dn": dn, "source_key": source_key, "source_revision": source_revision, "authority": "external_authoritative", } }, ) def _ldap_source_revision( attributes: dict[str, Any], *, attribute_map: dict[str, str], serialized_attributes: dict[str, Any], ) -> str: configured = attribute_map.get("source_revision") for attribute in (configured, "modifyTimestamp", "uSNChanged", "entryCSN"): if attribute: value = _ldap_scalar(attributes.get(attribute)) if value: return value return hashlib.sha256( json.dumps(serialized_attributes, sort_keys=True, ensure_ascii=True).encode() ).hexdigest() def _ldap_values(value: Any) -> tuple[Any, ...]: if value is None: return () if isinstance(value, (list, tuple, set)): return tuple(value) return (value,) def _ldap_scalar(value: Any) -> str | None: values = _ldap_values(value) if not values: return None selected = values[0] if isinstance(selected, bytes): return selected.hex() normalized = str(selected).strip() return normalized or None def _json_safe_ldap_attributes(attributes: dict[str, Any]) -> dict[str, Any]: result: dict[str, Any] = {} for key, value in attributes.items(): values = _ldap_values(value) normalized = [item.hex() if isinstance(item, bytes) else str(item) for item in values] result[str(key)] = normalized if isinstance(value, (list, tuple, set)) else (normalized[0] if normalized else None) return result def _ldap_contacts_for_source(session: Session, sync_source: AddressSyncSource) -> list[Contact]: return ( session.query(Contact) .filter( Contact.address_book_id == sync_source.address_book_id, Contact.source_kind.in_(("ldap", "active_directory")), Contact.source_ref.like(f"ldap:{sync_source.id}:%"), ) .order_by(Contact.id.asc()) .all() ) def _build_carddav_sync_plan( session: Session, principal: ApiPrincipal, sync_source: AddressSyncSource, *, force_full: bool, password: str | None, bearer_token: str | None, client: AddressCardDAVClient | None, ) -> AddressSyncPlan: client = client or _carddav_client_for_source(session, sync_source, password=password, bearer_token=bearer_token) stats = AddressSyncPlanStats() plan = AddressSyncPlan(sync_source=sync_source, stats=stats) collection_props = AddressCardDAVReportResult() try: try: collection_props = client.propfind_collection() except AddressCardDAVError as exc: plan.items.append(AddressSyncPlanItem(action="unchanged", message=f"Collection PROPFIND failed; continuing with REPORT: {exc}")) if sync_source.sync_token and not force_full: try: report = client.sync_collection(sync_source.sync_token) stats.used_sync_token = True except AddressCardDAVSyncUnsupported as exc: plan.items.append(AddressSyncPlanItem(action="unchanged", message=f"Sync token REPORT failed; falling back to full sync: {exc}")) report = client.list_objects() stats.full_sync = True else: report = client.list_objects() stats.full_sync = True stats.sync_token = report.sync_token or collection_props.sync_token or sync_source.sync_token stats.etag = report.ctag or collection_props.ctag or sync_source.etag stats.remote_revision = report.ctag or collection_props.ctag or sync_source.remote_revision _plan_carddav_report(session, sync_source=sync_source, client=client, report=report, plan=plan) except AddressCardDAVError as exc: plan.items.append(AddressSyncPlanItem(action="error", message=str(exc))) stats.errors += 1 return plan def _plan_carddav_report( session: Session, *, sync_source: AddressSyncSource, client: AddressCardDAVClient, report: AddressCardDAVReportResult, plan: AddressSyncPlan, ) -> None: local_by_href = _carddav_contacts_by_href(session, sync_source) seen_hrefs: set[str] = set() reads_remote = _sync_source_reads_remote(sync_source) for item in report.objects: href = item.href seen_hrefs.add(href) _plan_carddav_report_object( sync_source=sync_source, client=client, item=item, local=local_by_href.get(href), reads_remote=reads_remote, plan=plan, ) if plan.stats.full_sync: _plan_carddav_full_sync_absences( sync_source=sync_source, local_by_href=local_by_href, seen_hrefs=seen_hrefs, reads_remote=reads_remote, plan=plan, ) _plan_carddav_outbound_local_changes(session, sync_source=sync_source, plan=plan) def _plan_carddav_report_object( *, sync_source: AddressSyncSource, client: AddressCardDAVClient, item: AddressCardDAVObject, local: Contact | None, reads_remote: bool, plan: AddressSyncPlan, ) -> None: if item.deleted: _plan_carddav_remote_deletion( sync_source=sync_source, item=item, local=local, reads_remote=reads_remote, plan=plan, ) return loaded = _load_carddav_report_vcard(client=client, item=item, plan=plan) if loaded is None: return raw_vcard, parsed = loaded _plan_carddav_live_object( sync_source=sync_source, item=item, local=local, reads_remote=reads_remote, raw_vcard=raw_vcard, parsed=parsed, plan=plan, ) def _plan_carddav_remote_deletion( *, sync_source: AddressSyncSource, item: AddressCardDAVObject, local: Contact | None, reads_remote: bool, plan: AddressSyncPlan, ) -> None: if local is None or local.deleted_at is not None: _add_sync_plan_item( plan, AddressSyncPlanItem( action="unchanged", href=item.href, etag=item.etag, message="Remote delete already reflected locally.", ), ) return if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): _add_sync_plan_item( plan, AddressSyncPlanItem( action="conflict", href=item.href, contact_id=local.id, display_name=local.display_name, etag=item.etag, message="Remote object was deleted while the local contact changed since the last successful sync.", ), ) elif reads_remote: _add_sync_plan_item( plan, AddressSyncPlanItem( action="delete", href=item.href, contact_id=local.id, display_name=local.display_name, etag=item.etag, ), ) def _load_carddav_report_vcard( *, client: AddressCardDAVClient, item: AddressCardDAVObject, plan: AddressSyncPlan, ) -> tuple[str, ParsedVCard] | None: raw_vcard = item.address_data if not raw_vcard: try: raw_vcard = client.fetch_object(item.href) except AddressCardDAVError as exc: _add_sync_plan_item( plan, AddressSyncPlanItem(action="error", href=item.href, etag=item.etag, message=str(exc)), ) return None parsed, error_message = _parse_single_remote_vcard(raw_vcard) if parsed is None: _add_sync_plan_item( plan, AddressSyncPlanItem( action="error", href=item.href, etag=item.etag, message=error_message or "Remote vCard could not be parsed.", ), ) return None return raw_vcard, parsed def _plan_carddav_live_object( *, sync_source: AddressSyncSource, item: AddressCardDAVObject, local: Contact | None, reads_remote: bool, raw_vcard: str, parsed: ParsedVCard, plan: AddressSyncPlan, ) -> None: if local is None: _plan_new_carddav_remote_object( item=item, reads_remote=reads_remote, raw_vcard=raw_vcard, parsed=parsed, plan=plan, ) return remote_revision = item.etag or parsed.source_revision if _carddav_local_delete_needs_push(local, sync_source): _add_sync_plan_item( plan, AddressSyncPlanItem( action="remote_delete", href=item.href, remote_uid=parsed.source_ref, contact_id=local.id, display_name=local.display_name, etag=item.etag or local.source_revision, message="Local delete will be pushed to CardDAV.", ), ) return if local.deleted_at is None and local.source_revision == remote_revision: _plan_matching_carddav_revision( sync_source=sync_source, item=item, local=local, parsed=parsed, plan=plan, ) return if _local_contact_changed_after_last_sync(local, sync_source): _add_sync_plan_item( plan, AddressSyncPlanItem( action="conflict", href=item.href, remote_uid=parsed.source_ref, contact_id=local.id, display_name=local.display_name, etag=item.etag, raw_vcard=raw_vcard, parsed_payload=parsed.payload, source_revision=item.etag or parsed.source_revision, message="Local contact changed since the last successful sync.", ), ) return _plan_carddav_remote_update( item=item, local=local, reads_remote=reads_remote, raw_vcard=raw_vcard, parsed=parsed, remote_revision=remote_revision, plan=plan, ) def _plan_new_carddav_remote_object( *, item: AddressCardDAVObject, reads_remote: bool, raw_vcard: str, parsed: ParsedVCard, plan: AddressSyncPlan, ) -> None: if reads_remote: _add_sync_plan_item( plan, AddressSyncPlanItem( action="create", href=item.href, remote_uid=parsed.source_ref, display_name=parsed.payload.display_name, etag=item.etag, raw_vcard=raw_vcard, parsed_payload=parsed.payload, source_revision=item.etag or parsed.source_revision, ), ) else: _add_sync_plan_item( plan, AddressSyncPlanItem( action="unchanged", href=item.href, etag=item.etag, message="Remote object ignored by export-only sync source.", ), ) def _carddav_local_delete_needs_push(local: Contact, sync_source: AddressSyncSource) -> bool: return bool( local.deleted_at is not None and _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source) ) def _plan_matching_carddav_revision( *, sync_source: AddressSyncSource, item: AddressCardDAVObject, local: Contact, parsed: ParsedVCard, plan: AddressSyncPlan, ) -> None: if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(local, sync_source): _add_sync_plan_item( plan, AddressSyncPlanItem( action="remote_update", href=item.href, remote_uid=parsed.source_ref, contact_id=local.id, display_name=local.display_name, etag=item.etag or local.source_revision, raw_vcard=_carddav_contact_vcard(local, href=item.href), source_revision=item.etag or local.source_revision, message="Local update will be pushed to CardDAV.", ), ) return _add_sync_plan_item( plan, AddressSyncPlanItem( action="unchanged", href=item.href, remote_uid=parsed.source_ref, contact_id=local.id, display_name=local.display_name, etag=item.etag, ), ) def _plan_carddav_remote_update( *, item: AddressCardDAVObject, local: Contact, reads_remote: bool, raw_vcard: str, parsed: ParsedVCard, remote_revision: str | None, plan: AddressSyncPlan, ) -> None: if reads_remote: _add_sync_plan_item( plan, AddressSyncPlanItem( action="update", href=item.href, remote_uid=parsed.source_ref, contact_id=local.id, display_name=parsed.payload.display_name or local.display_name, etag=item.etag, raw_vcard=raw_vcard, parsed_payload=parsed.payload, source_revision=remote_revision, ), ) else: _add_sync_plan_item( plan, AddressSyncPlanItem( action="unchanged", href=item.href, contact_id=local.id, display_name=local.display_name, etag=item.etag, message="Remote update ignored by export-only sync source.", ), ) def _plan_carddav_full_sync_absences( *, sync_source: AddressSyncSource, local_by_href: dict[str, Contact], seen_hrefs: set[str], reads_remote: bool, plan: AddressSyncPlan, ) -> None: for href, contact in local_by_href.items(): if href in seen_hrefs or contact.deleted_at is not None: continue if _sync_source_writes_remote(sync_source) and _local_contact_changed_after_last_sync(contact, sync_source): _add_sync_plan_item( plan, AddressSyncPlanItem( action="conflict", href=href, contact_id=contact.id, display_name=contact.display_name, etag=contact.source_revision, message="Remote object is absent from full sync, but the local contact changed since the last successful sync.", ), ) elif reads_remote: _add_sync_plan_item( plan, AddressSyncPlanItem( action="delete", href=href, contact_id=contact.id, display_name=contact.display_name, message="Remote object is absent from full sync.", ), ) def _plan_carddav_outbound_local_changes( session: Session, *, sync_source: AddressSyncSource, plan: AddressSyncPlan, ) -> None: if not _sync_source_writes_remote(sync_source): return reserved_contact_ids = {item.contact_id for item in plan.items if item.contact_id and item.action != "unchanged"} for contact in _carddav_contacts_for_source(session, sync_source): if contact.id in reserved_contact_ids: continue if contact.source_ref: if not _local_contact_changed_after_last_sync(contact, sync_source): continue if contact.deleted_at is not None: if contact.source_revision: _add_sync_plan_item( plan, AddressSyncPlanItem( action="remote_delete", href=contact.source_ref, contact_id=contact.id, display_name=contact.display_name, etag=contact.source_revision, message="Local delete will be pushed to CardDAV.", ), ) else: _add_sync_plan_item( plan, AddressSyncPlanItem( action="conflict", href=contact.source_ref, contact_id=contact.id, display_name=contact.display_name, message="Local delete cannot be pushed because no remote ETag is known.", ), ) continue if contact.source_revision: _add_sync_plan_item( plan, AddressSyncPlanItem( action="remote_update", href=contact.source_ref, contact_id=contact.id, display_name=contact.display_name, etag=contact.source_revision, raw_vcard=_carddav_contact_vcard(contact, href=contact.source_ref), source_revision=contact.source_revision, message="Local update will be pushed to CardDAV.", ), ) else: _add_sync_plan_item( plan, AddressSyncPlanItem( action="conflict", href=contact.source_ref, contact_id=contact.id, display_name=contact.display_name, message="Local update cannot be pushed because no remote ETag is known.", ), ) continue if contact.deleted_at is None: href = _carddav_new_contact_href(sync_source, contact) _add_sync_plan_item( plan, AddressSyncPlanItem( action="remote_create", href=href, contact_id=contact.id, display_name=contact.display_name, raw_vcard=_carddav_contact_vcard(contact, href=href), message="Local contact will be created in CardDAV.", ), ) def _add_sync_plan_item(plan: AddressSyncPlan, item: AddressSyncPlanItem) -> None: plan.items.append(item) if item.action in {"create", "remote_create"}: plan.stats.created += 1 plan.stats.fetched += 1 elif item.action in {"update", "remote_update"}: plan.stats.updated += 1 plan.stats.fetched += 1 elif item.action in {"delete", "remote_delete"}: plan.stats.deleted += 1 elif item.action == "conflict": plan.stats.conflicts += 1 plan.stats.fetched += 1 elif item.action == "unchanged": plan.stats.unchanged += 1 elif item.action == "error": plan.stats.errors += 1 def _apply_address_sync_plan(session: Session, principal: ApiPrincipal, plan: AddressSyncPlan, *, client: AddressCardDAVClient | None = None) -> None: for item in plan.items: if item.action == "create": contact = _upsert_remote_contact(session, principal, plan.sync_source, item) item.contact_id = contact.id elif item.action == "update": contact = _upsert_remote_contact(session, principal, plan.sync_source, item) item.contact_id = contact.id elif item.action == "delete" and item.contact_id: contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True) previous = _contact_change_payload(contact, prefix="previous_") contact.deleted_at = utcnow() contact.updated_by_account_id = _account_id(principal) record_sync_tombstone( session, principal, plan.sync_source.id, AddressSyncTombstoneCreateRequest( contact_id=contact.id, remote_uid=item.remote_uid, resource_href=item.href, local_deleted_at=contact.deleted_at, remote_deleted_at=utcnow(), synced_at=utcnow(), ), ) _record_address_contact_change(session, principal, contact=contact, operation="deleted", previous=previous) elif item.action in REMOTE_SYNC_ACTIONS: if client is None: raise AddressBookError("CardDAV write client is required for outbound sync.") try: _apply_carddav_remote_sync_item(session, principal, plan.sync_source, item, client=client) except AddressCardDAVPreconditionFailed as exc: _record_carddav_write_conflict(session, principal, plan, item, message=str(exc)) elif item.action == "conflict": local_value = {"contact_id": item.contact_id, "display_name": item.display_name} if item.contact_id: contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True) local_value["payload"] = _contact_payload_for_conflict(contact) remote_payload = item.parsed_payload.model_dump(mode="json") if item.parsed_payload else None record_sync_conflict( session, principal, plan.sync_source.id, AddressSyncConflictCreateRequest( contact_id=item.contact_id, remote_uid=item.remote_uid, resource_href=item.href, field_path="vcard", local_value=local_value, remote_value={"payload": remote_payload, "display_name": item.parsed_payload.display_name if item.parsed_payload else None, "etag": item.etag}, metadata={"message": item.message, "raw_vcard": item.raw_vcard, "remote_payload": remote_payload, "source_revision": item.source_revision}, ), ) session.flush() def _apply_carddav_remote_sync_item( session: Session, principal: ApiPrincipal, sync_source: AddressSyncSource, item: AddressSyncPlanItem, *, client: AddressCardDAVClient, ) -> None: if not item.contact_id: raise AddressBookError("Outbound sync item is missing a contact id.") contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True) if contact.address_book_id != sync_source.address_book_id: raise AddressBookError("Outbound sync contact must belong to the sync source address book.") if item.action == "remote_create": href = item.href or _carddav_new_contact_href(sync_source, contact) raw_vcard = item.raw_vcard or _carddav_contact_vcard(contact, href=href) previous = _contact_change_payload(contact, prefix="previous_") result = client.put_object(href, raw_vcard, create=True) _mark_carddav_contact_synced(contact, sync_source=sync_source, href=href, raw_vcard=raw_vcard, etag=result.etag or item.source_revision or contact.source_revision) contact.updated_by_account_id = _account_id(principal) _record_address_contact_change(session, principal, contact=contact, operation="synced", previous=previous) elif item.action == "remote_update": if not item.href: raise AddressBookError("Outbound update is missing a CardDAV href.") etag = item.etag or contact.source_revision raw_vcard = item.raw_vcard or _carddav_contact_vcard(contact, href=item.href) previous = _contact_change_payload(contact, prefix="previous_") result = client.put_object(item.href, raw_vcard, etag=etag) _mark_carddav_contact_synced(contact, sync_source=sync_source, href=item.href, raw_vcard=raw_vcard, etag=result.etag or etag) contact.updated_by_account_id = _account_id(principal) _record_address_contact_change(session, principal, contact=contact, operation="synced", previous=previous) elif item.action == "remote_delete": if not item.href: raise AddressBookError("Outbound delete is missing a CardDAV href.") etag = item.etag or contact.source_revision previous = _contact_change_payload(contact, prefix="previous_") client.delete_object(item.href, etag=etag) record_sync_tombstone( session, principal, sync_source.id, AddressSyncTombstoneCreateRequest( contact_id=contact.id, remote_uid=item.remote_uid, resource_href=item.href, local_deleted_at=contact.deleted_at, remote_deleted_at=utcnow(), synced_at=utcnow(), ), ) contact.updated_by_account_id = _account_id(principal) _record_address_contact_change(session, principal, contact=contact, operation="synced", previous=previous) def _mark_carddav_contact_synced( contact: Contact, *, sync_source: AddressSyncSource, href: str, raw_vcard: str, etag: str | None, ) -> None: contact.source_kind = sync_source.connector_type contact.source_ref = href contact.source_payload_kind = "vcard" contact.source_payload_raw = raw_vcard contact.source_revision = etag provenance = dict(contact.provenance or {}) provenance["carddav"] = { "sync_source_id": sync_source.id, "href": href, "etag": etag, "last_push_at": utcnow().isoformat(), } contact.provenance = provenance def _record_carddav_write_conflict( session: Session, principal: ApiPrincipal, plan: AddressSyncPlan, item: AddressSyncPlanItem, *, message: str, ) -> None: original_action = item.action _decrement_sync_plan_stats(plan, original_action) item.action = "conflict" item.message = message plan.stats.conflicts += 1 local_value: dict[str, Any] = {"contact_id": item.contact_id, "display_name": item.display_name, "action": original_action} if item.contact_id: contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True) local_value["payload"] = _contact_payload_for_conflict(contact) record_sync_conflict( session, principal, plan.sync_source.id, AddressSyncConflictCreateRequest( contact_id=item.contact_id, remote_uid=item.remote_uid, resource_href=item.href, field_path="vcard", local_value=local_value, remote_value={"etag": item.etag, "message": message}, metadata={"message": message, "source": "carddav_write"}, ), ) def _decrement_sync_plan_stats(plan: AddressSyncPlan, action: str) -> None: if action == "remote_create": plan.stats.created = max(0, plan.stats.created - 1) plan.stats.fetched = max(0, plan.stats.fetched - 1) elif action == "remote_update": plan.stats.updated = max(0, plan.stats.updated - 1) plan.stats.fetched = max(0, plan.stats.fetched - 1) elif action == "remote_delete": plan.stats.deleted = max(0, plan.stats.deleted - 1) def _upsert_remote_contact( session: Session, principal: ApiPrincipal, sync_source: AddressSyncSource, item: AddressSyncPlanItem, ) -> Contact: if item.parsed_payload is None: raise AddressBookError("Mapped remote contact payload is missing.") contact = None if item.contact_id: contact = get_visible_contact(session, principal, item.contact_id, include_deleted=True) if contact is None: contact = Contact( tenant_id=sync_source.tenant_id, address_book_id=sync_source.address_book_id, display_name=_display_name_from_payload(item.parsed_payload), created_by_account_id=_account_id(principal), ) session.add(contact) operation = "created" previous = None else: previous = _contact_change_payload(contact, prefix="previous_") operation = "created" if contact.deleted_at is not None else "updated" contact.display_name = _display_name_from_payload(item.parsed_payload, fallback=contact.display_name) contact.given_name = _trim(item.parsed_payload.given_name) contact.family_name = _trim(item.parsed_payload.family_name) contact.organization = _trim(item.parsed_payload.organization) contact.role_title = _trim(item.parsed_payload.role_title) contact.note = _trim(item.parsed_payload.note) contact.tags = _normalize_tags(item.parsed_payload.tags) contact.source_kind = sync_source.connector_type contact.source_ref = item.href contact.source_payload_kind = "vcard" if sync_source.connector_type == "carddav" else "ldap-entry" contact.source_payload_raw = item.raw_vcard if sync_source.connector_type == "carddav" else None contact.source_revision = item.source_revision if sync_source.connector_type == "carddav": source_provenance = { "carddav": { "sync_source_id": sync_source.id, "href": item.href, "remote_uid": item.remote_uid, "etag": item.etag, } } else: source_provenance = { "ldap": { "sync_source_id": sync_source.id, "source_ref": item.href, "source_key": item.remote_uid, "source_revision": item.source_revision, "dn": item.source_details.get("dn"), "authority": "external_authoritative", } } contact.provenance = {**(item.parsed_payload.provenance or {}), **source_provenance} contact.deleted_at = None contact.updated_by_account_id = _account_id(principal) _replace_emails(contact, item.parsed_payload.emails) _replace_phones(contact, item.parsed_payload.phones) _replace_postal_addresses(contact, item.parsed_payload.postal_addresses) session.flush() _record_address_contact_change(session, principal, contact=contact, operation=operation, previous=previous) return contact def _parse_single_remote_vcard(raw_vcard: str): result = parse_vcards_with_issues(raw_vcard) if not result.cards: message = result.issues[0].message if result.issues else "No vCard entries found." return None, message return result.cards[0], "; ".join(issue.message for issue in result.issues if issue.severity == "error") or None def _carddav_contacts_by_href(session: Session, sync_source: AddressSyncSource) -> dict[str, Contact]: return {str(contact.source_ref): contact for contact in _carddav_contacts_for_source(session, sync_source) if contact.source_ref} def _carddav_contacts_for_source(session: Session, sync_source: AddressSyncSource) -> list[Contact]: rows = ( session.query(Contact) .filter( Contact.address_book_id == sync_source.address_book_id, ) .all() ) return rows def _sync_source_writes_remote(sync_source: AddressSyncSource) -> bool: return sync_source.sync_direction in OUTBOUND_SYNC_DIRECTIONS and not sync_source.read_only def _sync_source_reads_remote(sync_source: AddressSyncSource) -> bool: return sync_source.sync_direction in INBOUND_SYNC_DIRECTIONS def _carddav_new_contact_href(sync_source: AddressSyncSource, contact: Contact) -> str: url = _trim(sync_source.external_address_book_ref) if not url: return f"{contact.id}.vcf" try: collection = ensure_collection_url(url) except AddressCardDAVError: return f"{contact.id}.vcf" path = urllib.parse.urlparse(collection).path.rstrip("/") if not path: return f"{contact.id}.vcf" return f"{path}/{contact.id}.vcf" def _carddav_contact_vcard(contact: Contact, *, href: str | None = None) -> str: content = contacts_to_vcard([contact]) if href and "UID:" not in content: content = content.replace("VERSION:4.0\r\n", f"VERSION:4.0\r\nUID:{href}\r\n", 1) return content def _local_contact_changed_after_last_sync(contact: Contact, sync_source: AddressSyncSource) -> bool: if sync_source.last_success_at is None: return False if contact.deleted_at and contact.deleted_at > sync_source.last_success_at: return True return bool(contact.updated_at and contact.updated_at > sync_source.last_success_at) def _ldap_client_from_connection_payload( session: Session, principal: ApiPrincipal, payload: AddressLdapConnectionRequest, ) -> AddressLdapClient: reusable = _resolve_core_address_credential( session, tenant_id=principal.tenant_id, source_id=None, credential_ref=payload.credential_ref, ) bind_dn = _trim(payload.bind_dn) or _credential_username(reusable) password = _credential_secret(reusable, auth_type="basic") if reusable is not None else None return AddressLdapClient( url=payload.url, bind_dn=bind_dn, password=password, start_tls=payload.start_tls, connect_timeout=payload.connect_timeout, receive_timeout=payload.receive_timeout, ) def _ldap_client_for_source( session: Session, sync_source: AddressSyncSource, ) -> AddressLdapClient: settings = _ldap_metadata(sync_source.metadata_) reusable = _resolve_core_address_credential( session, tenant_id=sync_source.tenant_id or "", source_id=sync_source.id, credential_ref=settings.get("credential_ref"), ) bind_dn = _trim(str(settings.get("bind_dn") or "")) or _credential_username(reusable) password = _credential_secret(reusable, auth_type="basic") if reusable is not None else None return AddressLdapClient( url=str(settings.get("url") or sync_source.external_account_ref or ""), bind_dn=bind_dn, password=password, start_tls=bool(settings.get("start_tls", True)), connect_timeout=int(settings.get("connect_timeout") or 10), receive_timeout=int(settings.get("receive_timeout") or 30), ) def _carddav_client_from_payload( session: Session, principal: ApiPrincipal, payload: AddressCardDavDiscoveryRequest, *, source: AddressSyncSource | None = None, ) -> AddressCardDAVClient: _assert_no_caller_carddav_credential_ref(payload.credential_ref) url = payload.url or (source.external_address_book_ref if source else "") metadata = dict(source.metadata_ or {}) if source else {} auth = dict(metadata.get("carddav") or {}) auth_type = payload.auth_type or str(auth.get("auth_type") or "none") username = payload.username if payload.username is not None else auth.get("username") password = _secret_value(payload.password) bearer_token = _secret_value(payload.bearer_token) credential_ref = payload.credential_ref if payload.credential_ref is not None else auth.get("credential_ref") reusable = _resolve_core_address_credential( session, tenant_id=principal.tenant_id, source_id=source.id if source else None, credential_ref=credential_ref, ) username = username or _credential_username(reusable) secret = _resolve_carddav_secret( session=session, tenant_id=principal.tenant_id, source_id=source.id if source else None, auth_type=auth_type, password=password, bearer_token=bearer_token, credential_ref=credential_ref, encrypted=auth.get("secret_encrypted"), ) return _new_carddav_client(url, auth_type=auth_type, username=username, secret=secret) def _carddav_client_for_source( session: Session, sync_source: AddressSyncSource, *, password: str | None = None, bearer_token: str | None = None, ) -> AddressCardDAVClient: metadata = dict(sync_source.metadata_ or {}) auth = dict(metadata.get("carddav") or {}) auth_type = str(auth.get("auth_type") or "none") username = auth.get("username") reusable = _resolve_core_address_credential( session, tenant_id=sync_source.tenant_id or "", source_id=sync_source.id, credential_ref=auth.get("credential_ref"), ) username = username or _credential_username(reusable) secret = _resolve_carddav_secret( session=session, tenant_id=sync_source.tenant_id or "", source_id=sync_source.id, auth_type=auth_type, password=password, bearer_token=bearer_token, credential_ref=auth.get("credential_ref"), encrypted=auth.get("secret_encrypted"), ) return _new_carddav_client(sync_source.external_address_book_ref or "", auth_type=auth_type, username=username, secret=secret) def _new_carddav_client(url: str, *, auth_type: str, username: str | None, secret: str | None) -> AddressCardDAVClient: if auth_type == "basic": if not username: raise AddressBookError("CardDAV basic auth requires a username.") if not secret: raise AddressBookError("CardDAV basic auth requires a password or credential reference.") return AddressCardDAVClient(collection_url=url, username=username, password=secret) if auth_type == "bearer": if not secret: raise AddressBookError("CardDAV bearer auth requires a token or credential reference.") return AddressCardDAVClient(collection_url=url, bearer_token=secret) return AddressCardDAVClient(collection_url=url) def _carddav_metadata( *, auth_type: str, username: str | None, password: str | None, bearer_token: str | None, credential_ref: str | None, collection_url: str, ) -> dict[str, Any]: secret = password if auth_type == "basic" else bearer_token if auth_type == "bearer" else None auth: dict[str, Any] = { "auth_type": auth_type, "username": _trim(username), "credential_ref": _trim(credential_ref), } if secret: auth["secret_encrypted"] = encrypt_secret(secret) return { "carddav": auth, "collection_url": collection_url, } def _resolve_carddav_secret( *, session: Session, tenant_id: str, source_id: str | None, auth_type: str, password: str | None, bearer_token: str | None, credential_ref: str | None, encrypted: str | None, ) -> str | None: if auth_type == "basic" and password: return password if auth_type == "bearer" and bearer_token: return bearer_token if credential_ref: reusable = _resolve_core_address_credential( session, tenant_id=tenant_id, source_id=source_id, credential_ref=credential_ref, ) if reusable is None: raise AddressBookError( "The CardDAV credential reference is not a server-owned credential or visible reusable credential envelope." ) return _credential_secret(reusable, auth_type=auth_type) if encrypted: return decrypt_secret(encrypted) return None def _core_credential_id(credential_ref: str | None) -> str | None: if not credential_ref or not credential_ref.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX): return None value = credential_ref.removeprefix(CORE_CREDENTIAL_ENVELOPE_PREFIX).strip() return value or None def _resolve_core_address_credential( session: Session, *, tenant_id: str, source_id: str | None, credential_ref: str | None, ) -> ResolvedCredentialEnvelope | None: credential_id = _core_credential_id(credential_ref) if credential_id is None: return None try: return resolve_credential_envelope( session, credential_id=credential_id, context=address_credential_context( tenant_id=tenant_id, source_id=source_id, ), ) except CredentialEnvelopeError as exc: raise AddressBookError( "The selected reusable credential is unavailable to this CardDAV source." ) from exc def _credential_username(credential: ResolvedCredentialEnvelope | None) -> str | None: if credential is None: return None value = credential.public_data.get("username") return _trim(value) def _credential_secret(credential: ResolvedCredentialEnvelope, *, auth_type: str) -> str | None: keys = ( ("password", "secret", "token") if auth_type == "basic" else ("access_token", "bearer_token", "token", "password", "secret") ) for key in keys: value = credential.secret_data.get(key) if value is not None and str(value): return str(value) return None def resolve_trusted_deployment_carddav_credential_ref(credential_ref: str) -> str | None: """Resolve env-backed credentials only for trusted deployment code. API-managed discovery and sync-source paths deliberately never call this function, so a tenant user cannot select arbitrary process environment variables as connector credentials. """ if not credential_ref.startswith(CARDDAV_SECRET_ENV_PREFIX): raise AddressBookError("Trusted deployment credential references must use the env: prefix") env_name = credential_ref.removeprefix(CARDDAV_SECRET_ENV_PREFIX) if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", env_name): raise AddressBookError("Trusted deployment credential reference contains an invalid environment variable name") return os.environ.get(env_name) def public_address_sync_metadata(metadata: object) -> dict[str, Any]: payload = copy.deepcopy(metadata) if isinstance(metadata, dict) else {} auth = payload.get("carddav") if isinstance(auth, dict): had_credential = bool(auth.get("secret_encrypted") or auth.get("credential_ref")) auth["credential_envelope_id"] = _core_credential_id(auth.get("credential_ref")) auth.pop("secret_encrypted", None) auth.pop("credential_ref", None) auth["has_credential"] = had_credential ldap = payload.get("ldap") if isinstance(ldap, dict): credential_ref = ldap.pop("credential_ref", None) ldap["credential_envelope_id"] = _core_credential_id(credential_ref) ldap["has_credential"] = bool(credential_ref) return payload def _assert_no_caller_carddav_credential_ref(credential_ref: str | None) -> None: value = _trim(credential_ref) if value and not value.startswith(CORE_CREDENTIAL_ENVELOPE_PREFIX): raise AddressBookError( "Caller-supplied credential references are accepted only for reusable credential envelopes." ) def _assert_reusable_credential_ref(credential_ref: str | None) -> None: _assert_no_caller_carddav_credential_ref(credential_ref) def _ldap_metadata(metadata: object) -> dict[str, Any]: if not isinstance(metadata, dict): return {} ldap = metadata.get("ldap") return ldap if isinstance(ldap, dict) else {} def _assert_api_ldap_metadata_safe(metadata: object) -> None: ldap = _ldap_metadata(metadata) forbidden = {"credential_ref", "password", "secret", "bind_password"}.intersection(ldap) if forbidden: raise AddressBookError( "LDAP credential references and secrets are server-managed; use the LDAP source endpoint." ) def _merge_server_owned_ldap_metadata(existing: object, incoming: dict[str, Any]) -> dict[str, Any]: merged = copy.deepcopy(incoming) existing_ldap = _ldap_metadata(existing) credential_ref = existing_ldap.get("credential_ref") if credential_ref: ldap = merged.get("ldap") if not isinstance(ldap, dict): ldap = {} merged["ldap"] = ldap ldap["credential_ref"] = credential_ref return merged def _carddav_auth_metadata(metadata: object) -> dict[str, Any]: if not isinstance(metadata, dict): return {} carddav = metadata.get("carddav") return carddav if isinstance(carddav, dict) else {} def _assert_api_carddav_metadata_safe(metadata: object) -> None: auth = _carddav_auth_metadata(metadata) if auth.get("credential_ref") or auth.get("secret_encrypted"): raise AddressBookError( "CardDAV credential references and encrypted secrets are server-managed; provide credentials through the CardDAV source endpoint" ) def _merge_server_owned_carddav_metadata(existing: object, incoming: dict[str, Any]) -> dict[str, Any]: merged = copy.deepcopy(incoming) existing_auth = _carddav_auth_metadata(existing) server_owned = { key: existing_auth[key] for key in ("credential_ref", "secret_encrypted") if existing_auth.get(key) } if not server_owned: return merged auth = merged.get("carddav") if not isinstance(auth, dict): auth = {} merged["carddav"] = auth auth.update(server_owned) return merged def _secret_value(value: Any | None) -> str | None: if value is None: return None if hasattr(value, "get_secret_value"): return str(value.get_secret_value()) return str(value) def _sync_plan_diagnostic(plan: AddressSyncPlan) -> dict[str, Any]: return { "created": plan.stats.created, "updated": plan.stats.updated, "deleted": plan.stats.deleted, "conflicts": plan.stats.conflicts, "unchanged": plan.stats.unchanged, "errors": plan.stats.errors, "full_sync": plan.stats.full_sync, "used_sync_token": plan.stats.used_sync_token, } def _contact_change_payload(contact: Contact, *, prefix: str = "") -> dict[str, Any]: return { f"{prefix}address_book_id": contact.address_book_id, f"{prefix}display_name": contact.display_name, f"{prefix}source_kind": contact.source_kind, f"{prefix}source_ref": contact.source_ref, f"{prefix}source_revision": contact.source_revision, } def _contact_payload_for_conflict(contact: Contact) -> dict[str, Any]: return { "display_name": contact.display_name, "given_name": contact.given_name, "family_name": contact.family_name, "organization": contact.organization, "role_title": contact.role_title, "note": contact.note, "tags": list(contact.tags or []), "emails": [ {"label": email.label, "email": email.email, "is_primary": email.is_primary} for email in sorted(contact.emails, key=lambda item: (item.order_index, item.id)) ], "phones": [ {"label": phone.label, "phone": phone.phone, "is_primary": phone.is_primary} for phone in sorted(contact.phones, key=lambda item: (item.order_index, item.id)) ], "postal_addresses": [ { "label": address.label, "street": address.street, "postal_code": address.postal_code, "locality": address.locality, "region": address.region, "country": address.country, "is_primary": address.is_primary, } for address in sorted(contact.postal_addresses, key=lambda item: (item.order_index, item.id)) ], } def _record_address_contact_change( session: Session, principal: ApiPrincipal, *, contact: Contact, operation: str, previous: dict[str, Any] | None = None, merge_record_id: str | None = None, ) -> None: session.flush() payload = _contact_change_payload(contact) if previous: payload.update(previous) record_change( session, module_id=ADDRESS_MODULE_ID, collection=ADDRESS_CONTACTS_COLLECTION, resource_type=ADDRESS_CONTACT_RESOURCE, resource_id=contact.id, operation=operation, tenant_id=contact.tenant_id, actor_type="user", actor_id=_account_id(principal), payload=payload, ) _record_contact_field_provenance( session, principal, contact=contact, reason_code=f"addresses.contact.{operation}", explanation=f"The retained value was selected during contact {operation}.", merge_record_id=merge_record_id, ) def _record_contact_field_provenance( session: Session, principal: ApiPrincipal, *, contact: Contact, reason_code: str, explanation: str, merge_record_id: str | None = None, ) -> None: session.flush() session.query(ContactFieldProvenance).filter( ContactFieldProvenance.contact_id == contact.id, ContactFieldProvenance.selected.is_(True), ).update({ContactFieldProvenance.selected: False}, synchronize_session=False) contact_provenance = contact.provenance if isinstance(contact.provenance, dict) else {} raw_visibility = contact_provenance.get("field_visibility") visibility_by_path = dict(raw_visibility) if isinstance(raw_visibility, dict) else {} raw_field_sources = contact_provenance.get("field_sources") field_sources = dict(raw_field_sources) if isinstance(raw_field_sources, dict) else {} for path, value, provenance in _retained_contact_fields(contact): field_source = field_sources.get(path) if isinstance(field_source, dict): provenance = {**provenance, **field_source} source_kind = str(provenance.get("source_kind") or contact.source_kind or "local") source_ref = provenance.get("source_ref") or contact.source_ref source_revision = provenance.get("source_revision") or contact.source_revision visibility = str(visibility_by_path.get(path) or "inherit") if visibility not in {"inherit", "private", "restricted", "public"}: visibility = "inherit" session.add( ContactFieldProvenance( tenant_id=contact.tenant_id, contact_id=contact.id, field_path=path, value=value, source_kind=source_kind, source_ref=str(source_ref) if source_ref else None, source_revision=str(source_revision) if source_revision else None, precedence=_source_precedence(source_kind), selected=True, reason_code=reason_code, explanation=explanation, visibility=visibility, merge_record_id=merge_record_id, created_by_account_id=_account_id(principal), metadata_={ "operation": reason_code.rsplit(".", 1)[-1], **{ key: value for key, value in provenance.items() if key not in {"source_kind", "source_ref", "source_revision"} }, }, ) ) def _retained_contact_fields( contact: Contact, ) -> list[tuple[str, Any, dict[str, Any]]]: contact_source = { "source_kind": contact.source_kind, "source_ref": contact.source_ref, "source_revision": contact.source_revision, } rows: list[tuple[str, Any, dict[str, Any]]] = [ (field_name, getattr(contact, field_name), contact_source) for field_name in ( "display_name", "given_name", "family_name", "organization", "role_title", "note", "tags", ) ] for point in contact.emails: provenance = dict(point.provenance or contact_source) base = f"emails.{point.id}" rows.extend( ( (f"{base}.label", point.label, provenance), (f"{base}.email", point.email, provenance), (f"{base}.is_primary", point.is_primary, provenance), ) ) for point in contact.phones: provenance = dict(point.provenance or contact_source) base = f"phones.{point.id}" rows.extend( ( (f"{base}.label", point.label, provenance), (f"{base}.phone", point.phone, provenance), (f"{base}.is_primary", point.is_primary, provenance), ) ) for point in contact.postal_addresses: provenance = dict(point.provenance or contact_source) base = f"postal_addresses.{point.id}" for field_name in ( "label", "street", "postal_code", "locality", "region", "country", "is_primary", ): rows.append((f"{base}.{field_name}", getattr(point, field_name), provenance)) return rows def _source_precedence(source_kind: str) -> int: return { "manual": 100, "local": 90, "carddav": 80, "microsoft_graph": 75, "google": 75, "ldap": 70, "vcard": 60, "csv": 50, }.get(source_kind, 40) def _read_only_from_sync_direction(sync_direction: str, explicit_read_only: bool | None) -> bool: if sync_direction in READ_ONLY_SYNC_DIRECTIONS: return True if explicit_read_only is not None: return explicit_read_only return False def _apply_sync_source_to_book(book: AddressBook, sync_source: AddressSyncSource) -> None: book.source_kind = sync_source.connector_type book.source_ref = sync_source.id book.read_only = sync_source.read_only or sync_source.sync_direction in READ_ONLY_SYNC_DIRECTIONS book.sync_status = sync_source.status book.sync_error = sync_source.last_error book.updated_by_account_id = sync_source.updated_by_account_id def _sync_contact_or_none(session: Session, principal: ApiPrincipal, sync_source: AddressSyncSource, contact_id: str | None) -> Contact | None: if not contact_id: return None contact = get_visible_contact(session, principal, contact_id, include_deleted=True) if contact.address_book_id != sync_source.address_book_id: raise AddressBookError("Sync contact must belong to the sync source address book.") return contact def _visible_address_lists_query(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False, include_deleted_books: bool = False): book_ids = [book.id for book in list_address_books(session, principal, include_deleted=include_deleted_books)] if not book_ids: return session.query(AddressList).filter(false()) query = session.query(AddressList).filter(AddressList.address_book_id.in_(book_ids)) if not include_deleted: query = query.filter(AddressList.deleted_at.is_(None)) return query def list_address_lists( session: Session, principal: ApiPrincipal, *, address_book_id: str | None = None, include_deleted: bool = False, ) -> list[AddressList]: query = _visible_address_lists_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted) if address_book_id: get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted) query = query.filter(AddressList.address_book_id == address_book_id) return query.order_by(AddressList.name.asc(), AddressList.id.asc()).all() def get_visible_address_list(session: Session, principal: ApiPrincipal, address_list_id: str, *, include_deleted: bool = False) -> AddressList: address_list = ( _visible_address_lists_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted) .filter(AddressList.id == address_list_id) .one_or_none() ) if address_list is None: raise AddressBookError("Address list not found.") return address_list def create_address_list(session: Session, principal: ApiPrincipal, address_book_id: str, payload: AddressListCreateRequest) -> AddressList: book = get_visible_address_book(session, principal, address_book_id) _require_mutable_book(book) name = _trim(payload.name) if not name: raise AddressBookError("Address list name is required.") address_list = AddressList( tenant_id=book.tenant_id, address_book_id=book.id, name=name, description=_trim(payload.description), source_kind=book.source_kind, read_only=book.read_only, created_by_account_id=_account_id(principal), updated_by_account_id=_account_id(principal), metadata_={}, ) session.add(address_list) return address_list def update_address_list(session: Session, principal: ApiPrincipal, address_list_id: str, payload: AddressListUpdateRequest) -> AddressList: address_list = get_visible_address_list(session, principal, address_list_id) _require_mutable_address_list(address_list) if "name" in payload.model_fields_set: name = _trim(payload.name) if not name: raise AddressBookError("Address list name is required.") address_list.name = name if "description" in payload.model_fields_set: address_list.description = _trim(payload.description) address_list.updated_by_account_id = _account_id(principal) return address_list def delete_address_list(session: Session, principal: ApiPrincipal, address_list_id: str) -> None: address_list = get_visible_address_list(session, principal, address_list_id) _require_mutable_address_list(address_list) address_list.deleted_at = utcnow() address_list.updated_by_account_id = _account_id(principal) def restore_address_list(session: Session, principal: ApiPrincipal, address_list_id: str) -> AddressList: address_list = get_visible_address_list(session, principal, address_list_id, include_deleted=True) _require_mutable_book(address_list.address_book) if address_list.read_only: raise AddressBookError("Address list is read-only.") address_list.deleted_at = None address_list.updated_by_account_id = _account_id(principal) return address_list def list_address_list_entries(session: Session, principal: ApiPrincipal, address_list_id: str) -> list[AddressListEntry]: address_list = get_visible_address_list(session, principal, address_list_id) return ( session.query(AddressListEntry) .join(Contact, AddressListEntry.contact_id == Contact.id) .filter(AddressListEntry.address_list_id == address_list.id, Contact.deleted_at.is_(None)) .order_by(AddressListEntry.order_index.asc(), AddressListEntry.id.asc()) .all() ) def create_address_list_entry( session: Session, principal: ApiPrincipal, address_list_id: str, payload: AddressListEntryCreateRequest, ) -> AddressListEntry: address_list = get_visible_address_list(session, principal, address_list_id) _require_mutable_address_list(address_list) contact = get_visible_contact(session, principal, payload.contact_id) if contact.address_book_id != address_list.address_book_id: raise AddressBookError("Address-list entries must reference contacts in the same address book.") contact_email = _contact_email_by_id(contact, payload.contact_email_id) postal_address = _contact_postal_address_by_id(contact, payload.contact_postal_address_id) if contact_email is not None and postal_address is not None: raise AddressBookError("Address-list entries may reference either an email address or a postal address, not both.") order_index = _next_address_list_entry_order(session, address_list.id) entry = AddressListEntry( address_list_id=address_list.id, contact_id=contact.id, contact_email_id=contact_email.id if contact_email is not None else None, contact_postal_address_id=postal_address.id if postal_address is not None else None, target_kind=_address_list_entry_kind(contact_email, postal_address), label=_trim(payload.label), order_index=order_index, metadata_={}, ) address_list.updated_by_account_id = _account_id(principal) session.add(entry) return entry def delete_address_list_entry(session: Session, principal: ApiPrincipal, entry_id: str) -> None: entry = get_visible_address_list_entry(session, principal, entry_id) _require_mutable_address_list(entry.address_list) entry.address_list.updated_by_account_id = _account_id(principal) session.delete(entry) def get_visible_address_list_entry(session: Session, principal: ApiPrincipal, entry_id: str) -> AddressListEntry: list_ids = [address_list.id for address_list in list_address_lists(session, principal)] if not list_ids: raise AddressBookError("Address-list entry not found.") entry = session.query(AddressListEntry).filter(AddressListEntry.id == entry_id, AddressListEntry.address_list_id.in_(list_ids)).one_or_none() if entry is None: raise AddressBookError("Address-list entry not found.") return entry def _contact_email_by_id(contact: Contact, email_id: str | None) -> ContactEmail | None: if not email_id: return None email = next((item for item in contact.emails if item.id == email_id), None) if email is None: raise AddressBookError("Contact email not found.") return email def _contact_postal_address_by_id(contact: Contact, postal_address_id: str | None) -> ContactPostalAddress | None: if not postal_address_id: return None postal_address = next((item for item in contact.postal_addresses if item.id == postal_address_id), None) if postal_address is None: raise AddressBookError("Contact postal address not found.") return postal_address def _address_list_entry_kind(contact_email: ContactEmail | None, postal_address: ContactPostalAddress | None) -> str: if contact_email is not None: return "email" if postal_address is not None: return "postal_address" return "contact" def _next_address_list_entry_order(session: Session, address_list_id: str) -> int: current = session.query(func.max(AddressListEntry.order_index)).filter(AddressListEntry.address_list_id == address_list_id).scalar() return int(current or 0) + 1 def create_address_book( session: Session, principal: ApiPrincipal, payload: AddressBookCreateRequest, *, allow_system: bool = False, ) -> AddressBook: name = _trim(payload.name) if not name: raise AddressBookError("Address book name is required.") tenant_id, scope_id = _scope_id_for_create(principal, payload, allow_system=allow_system) book = AddressBook( tenant_id=tenant_id, scope_type=payload.scope_type, scope_id=scope_id, name=name, description=_trim(payload.description), source_kind="local", read_only=False, created_by_account_id=_account_id(principal), updated_by_account_id=_account_id(principal), metadata_={}, ) session.add(book) return book def update_address_book(session: Session, principal: ApiPrincipal, book_id: str, payload: AddressBookUpdateRequest) -> AddressBook: book = get_visible_address_book(session, principal, book_id) _require_mutable_book(book) if "name" in payload.model_fields_set: name = _trim(payload.name) if not name: raise AddressBookError("Address book name is required.") book.name = name if "description" in payload.model_fields_set: book.description = _trim(payload.description) book.updated_by_account_id = _account_id(principal) return book def delete_address_book(session: Session, principal: ApiPrincipal, book_id: str) -> None: book = get_visible_address_book(session, principal, book_id) _require_mutable_book(book) deleted_at = utcnow() book.deleted_at = deleted_at book.updated_by_account_id = _account_id(principal) for contact in book.contacts: if contact.deleted_at is None: contact.deleted_at = deleted_at contact.updated_by_account_id = _account_id(principal) def restore_address_book(session: Session, principal: ApiPrincipal, book_id: str, *, restore_contacts: bool = True) -> AddressBook: book = get_visible_address_book(session, principal, book_id, include_deleted=True) if book.read_only: raise AddressBookError("Address book is read-only.") book.deleted_at = None book.updated_by_account_id = _account_id(principal) if restore_contacts: for contact in book.contacts: if contact.deleted_at is not None: contact.deleted_at = None contact.updated_by_account_id = _account_id(principal) return book def _primary_email(payloads: list[ContactEmailPayload]) -> str | None: if not payloads: return None primary = next((item.email for item in payloads if item.is_primary), None) return _trim(primary) or _trim(payloads[0].email) def _display_name_from_payload(payload: ContactCreateRequest | ContactUpdateRequest, *, fallback: str | None = None) -> str: explicit = _trim(payload.display_name) if explicit: return explicit parts = [_trim(payload.given_name), _trim(payload.family_name)] joined = " ".join(part for part in parts if part) if joined: return joined emails = getattr(payload, "emails", None) if isinstance(emails, list): email = _primary_email(emails) if email: return email if fallback: return fallback raise AddressBookError("Contact name or email is required.") def _normalize_tags(tags: list[str] | None) -> list[str]: seen: set[str] = set() normalized: list[str] = [] for tag in tags or []: value = tag.strip() key = value.casefold() if value and key not in seen: seen.add(key) normalized.append(value) return normalized def _normalize_match_text(value: str | None) -> str: if value is None: return "" return " ".join(unicodedata.normalize("NFKC", value).strip().casefold().split()) def _normalize_email(value: str | None) -> str: return _normalize_match_text(value) def _normalize_phone(value: str | None) -> str: if value is None: return "" normalized = unicodedata.normalize("NFKC", value).strip() prefix = "+" if normalized.startswith("+") else "" return prefix + re.sub(r"\D", "", normalized) def _normalized_postal_value(payload: ContactPostalAddressPayload) -> dict[str, str | None]: return { key: _normalize_match_text(getattr(payload, key)) or None for key in ("label", "street", "postal_code", "locality", "region", "country") } def _original_postal_value(payload: ContactPostalAddressPayload) -> dict[str, str | None]: return { key: getattr(payload, key) for key in ("label", "street", "postal_code", "locality", "region", "country") } def _contact_point_provenance(contact: Contact, *, point_kind: str) -> dict[str, Any]: return { "module": "addresses", "point_kind": point_kind, "source_kind": contact.source_kind, "source_ref": contact.source_ref, "source_revision": contact.source_revision, "supplied_by_account_id": contact.updated_by_account_id or contact.created_by_account_id, "original_value_preserved": True, } def _restamp_contact_point_provenance(contact: Contact) -> None: for point in contact.emails: point.provenance = _contact_point_provenance(contact, point_kind="email") for point in contact.phones: point.provenance = _contact_point_provenance(contact, point_kind="phone") for point in contact.postal_addresses: point.provenance = _contact_point_provenance(contact, point_kind="postal") def _replace_emails(contact: Contact, payloads: list[ContactEmailPayload]) -> None: contact.emails.clear() normalized = [_trim(item.email) for item in payloads] normalized_payloads = [(item, email) for item, email in zip(payloads, normalized, strict=False) if email] primary_index = next((index for index, (item, _email) in enumerate(normalized_payloads) if item.is_primary), 0) for index, (item, email) in enumerate(normalized_payloads): contact.emails.append( ContactEmail( label=_trim(item.label), email=email or "", original_email=item.email, normalized_email=_normalize_email(item.email), provenance=_contact_point_provenance(contact, point_kind="email"), is_primary=index == primary_index, order_index=index, ) ) def _replace_phones(contact: Contact, payloads: list[ContactPhonePayload]) -> None: contact.phones.clear() normalized = [_trim(item.phone) for item in payloads] normalized_payloads = [(item, phone) for item, phone in zip(payloads, normalized, strict=False) if phone] primary_index = next((index for index, (item, _phone) in enumerate(normalized_payloads) if item.is_primary), 0) for index, (item, phone) in enumerate(normalized_payloads): contact.phones.append( ContactPhone( label=_trim(item.label), phone=phone or "", original_phone=item.phone, normalized_phone=_normalize_phone(item.phone), provenance=_contact_point_provenance(contact, point_kind="phone"), is_primary=index == primary_index, order_index=index, ) ) def _postal_payload_has_content(payload: ContactPostalAddressPayload) -> bool: return any(_trim(value) for value in (payload.street, payload.postal_code, payload.locality, payload.region, payload.country)) def _replace_postal_addresses(contact: Contact, payloads: list[ContactPostalAddressPayload]) -> None: contact.postal_addresses.clear() normalized_payloads = [item for item in payloads if _postal_payload_has_content(item)] primary_index = next((index for index, item in enumerate(normalized_payloads) if item.is_primary), 0) for index, item in enumerate(normalized_payloads): contact.postal_addresses.append( ContactPostalAddress( label=_trim(item.label), street=_trim(item.street), postal_code=_trim(item.postal_code), locality=_trim(item.locality), region=_trim(item.region), country=_trim(item.country), original_value=_original_postal_value(item), normalized_value=_normalized_postal_value(item), provenance=_contact_point_provenance(contact, point_kind="postal"), is_primary=index == primary_index, order_index=index, ) ) def create_contact(session: Session, principal: ApiPrincipal, address_book_id: str, payload: ContactCreateRequest) -> Contact: book = get_visible_address_book(session, principal, address_book_id) _require_mutable_book(book) contact = Contact( tenant_id=book.tenant_id, address_book_id=book.id, display_name=_display_name_from_payload(payload), given_name=_trim(payload.given_name), family_name=_trim(payload.family_name), organization=_trim(payload.organization), role_title=_trim(payload.role_title), note=_trim(payload.note), tags=_normalize_tags(payload.tags), source_kind=book.source_kind, provenance=payload.provenance or {}, created_by_account_id=_account_id(principal), updated_by_account_id=_account_id(principal), metadata_={}, ) _replace_emails(contact, payload.emails) _replace_phones(contact, payload.phones) _replace_postal_addresses(contact, payload.postal_addresses) session.add(contact) _record_address_contact_change( session, principal, contact=contact, operation="created", ) return contact def import_vcards(session: Session, principal: ApiPrincipal, address_book_id: str, content: str) -> VCardImportResult: book = get_visible_address_book(session, principal, address_book_id) _require_mutable_book(book) contacts: list[Contact] = [] result = parse_vcards_with_issues(content) for parsed in result.cards: contact = create_contact(session, principal, book.id, parsed.payload) contact.source_kind = "vcard" contact.source_ref = _trim(parsed.source_ref) contact.source_payload_kind = "vcard" contact.source_payload_raw = parsed.raw contact.source_revision = _trim(parsed.source_revision) _restamp_contact_point_provenance(contact) _record_address_contact_change( session, principal, contact=contact, operation="imported", ) contacts.append(contact) return VCardImportResult(contacts=contacts, issues=result.issues, skipped=result.skipped) def _visible_contact_query(session: Session, principal: ApiPrincipal, *, include_deleted: bool = False, include_deleted_books: bool = False): book_ids = [book.id for book in list_address_books(session, principal, include_deleted=include_deleted_books)] if not book_ids: return session.query(Contact).filter(false()) query = ( session.query(Contact) .options( selectinload(Contact.emails), selectinload(Contact.phones), selectinload(Contact.postal_addresses), selectinload(Contact.quality_decisions), ) .filter(Contact.address_book_id.in_(book_ids)) ) if not include_deleted: query = query.filter(Contact.deleted_at.is_(None)) return query def list_contacts( session: Session, principal: ApiPrincipal, *, address_book_id: str | None = None, address_list_id: str | None = None, query: str | None = None, limit: int = 200, offset: int = 0, include_deleted: bool = False, ) -> list[Contact]: contact_query = _filtered_contact_query( session, principal, address_book_id=address_book_id, address_list_id=address_list_id, query=query, include_deleted=include_deleted, ) return ( contact_query.order_by(Contact.display_name.asc(), Contact.id.asc()) .offset(max(0, offset)) .limit(max(1, min(limit, 500))) .all() ) def count_contacts( session: Session, principal: ApiPrincipal, *, address_book_id: str | None = None, address_list_id: str | None = None, query: str | None = None, include_deleted: bool = False, ) -> int: contact_query = _filtered_contact_query( session, principal, address_book_id=address_book_id, address_list_id=address_list_id, query=query, include_deleted=include_deleted, ) return int( contact_query.order_by(None) .with_entities(func.count(func.distinct(Contact.id))) .scalar() or 0 ) def _filtered_contact_query( session: Session, principal: ApiPrincipal, *, address_book_id: str | None, address_list_id: str | None, query: str | None, include_deleted: bool, ): contact_query = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted) if address_book_id: get_visible_address_book(session, principal, address_book_id, include_deleted=include_deleted) contact_query = contact_query.filter(Contact.address_book_id == address_book_id) if address_list_id: address_list = get_visible_address_list( session, principal, address_list_id, include_deleted=include_deleted, ) if address_book_id and address_list.address_book_id != address_book_id: raise AddressBookError("Address list does not belong to the selected address book.") contact_query = contact_query.filter( exists().where( AddressListEntry.contact_id == Contact.id, AddressListEntry.address_list_id == address_list_id, ) ) normalized_query = _trim(query) if normalized_query: pattern = f"%{normalized_query.lower()}%" contact_query = contact_query.filter( or_( func.lower(Contact.display_name).like(pattern), func.lower(Contact.organization).like(pattern), exists().where( ContactEmail.contact_id == Contact.id, func.lower(ContactEmail.email).like(pattern), ), ) ) return contact_query def get_visible_contact(session: Session, principal: ApiPrincipal, contact_id: str, *, include_deleted: bool = False) -> Contact: contact = _visible_contact_query(session, principal, include_deleted=include_deleted, include_deleted_books=include_deleted).filter(Contact.id == contact_id).one_or_none() if contact is None: raise AddressBookError("Contact not found.") return contact def list_contact_channel_rules( session: Session, principal: ApiPrincipal, contact_id: str, ) -> list[ContactChannelRule]: contact = get_visible_contact(session, principal, contact_id) return ( session.query(ContactChannelRule) .filter(ContactChannelRule.contact_id == contact.id) .order_by(ContactChannelRule.created_at.desc(), ContactChannelRule.id.asc()) .all() ) def create_contact_channel_rule( session: Session, principal: ApiPrincipal, contact_id: str, payload: ContactChannelRuleCreateRequest, ) -> ContactChannelRule: contact = get_visible_contact(session, principal, contact_id) if ( payload.effective_from is not None and payload.effective_until is not None and payload.effective_until <= payload.effective_from ): raise AddressBookError("Channel-rule end must be after its start.") point_ids: set[str] = set() if payload.channel == "email": point_ids = {item.id for item in contact.emails} elif payload.channel == "postal": point_ids = {item.id for item in contact.postal_addresses} if payload.contact_point_id and payload.contact_point_id not in point_ids: raise AddressBookError( "The selected contact point does not belong to this contact and channel." ) rule = ContactChannelRule( tenant_id=contact.tenant_id, contact_id=contact.id, channel=payload.channel, purpose=_trim(payload.purpose), contact_point_id=payload.contact_point_id, decision=payload.decision, legal_basis=_trim(payload.legal_basis), evidence_ref=_trim(payload.evidence_ref), reason=_trim(payload.reason), preference_rank=payload.preference_rank, locale=_trim(payload.locale), effective_from=payload.effective_from, effective_until=payload.effective_until, created_by_account_id=_account_id(principal), metadata_=dict(payload.metadata), ) session.add(rule) session.flush() return rule def end_contact_channel_rule( session: Session, principal: ApiPrincipal, rule_id: str, ) -> ContactChannelRule: rule = session.get(ContactChannelRule, rule_id) if rule is None: raise AddressBookError("Contact channel rule not found.") get_visible_contact(session, principal, rule.contact_id) now = utcnow() if rule.effective_until is None or rule.effective_until > now: rule.effective_until = now session.add(rule) return rule def update_contact(session: Session, principal: ApiPrincipal, contact_id: str, payload: ContactUpdateRequest) -> Contact: contact = get_visible_contact(session, principal, contact_id) _require_mutable_book(contact.address_book) previous = _contact_change_payload(contact, prefix="previous_") fallback_name = contact.display_name if any(field in payload.model_fields_set for field in ("display_name", "given_name", "family_name", "emails")): contact.display_name = _display_name_from_payload(payload, fallback=fallback_name) for field_name, attr_name in ( ("given_name", "given_name"), ("family_name", "family_name"), ("organization", "organization"), ("role_title", "role_title"), ("note", "note"), ): if field_name in payload.model_fields_set: setattr(contact, attr_name, _trim(getattr(payload, field_name))) if "tags" in payload.model_fields_set: contact.tags = _normalize_tags(payload.tags) if "provenance" in payload.model_fields_set: contact.provenance = payload.provenance or {} if "emails" in payload.model_fields_set: _replace_emails(contact, payload.emails or []) if "phones" in payload.model_fields_set: _replace_phones(contact, payload.phones or []) if "postal_addresses" in payload.model_fields_set: _replace_postal_addresses(contact, payload.postal_addresses or []) contact.updated_by_account_id = _account_id(principal) _record_address_contact_change( session, principal, contact=contact, operation="updated", previous=previous, ) return contact def delete_contact(session: Session, principal: ApiPrincipal, contact_id: str) -> None: contact = get_visible_contact(session, principal, contact_id) _require_mutable_book(contact.address_book) previous = _contact_change_payload(contact, prefix="previous_") contact.deleted_at = utcnow() contact.updated_by_account_id = _account_id(principal) _record_address_contact_change( session, principal, contact=contact, operation="deleted", previous=previous, ) def restore_contact(session: Session, principal: ApiPrincipal, contact_id: str) -> Contact: contact = get_visible_contact(session, principal, contact_id, include_deleted=True) _require_mutable_book(contact.address_book) previous = _contact_change_payload(contact, prefix="previous_") contact.deleted_at = None contact.updated_by_account_id = _account_id(principal) _record_address_contact_change( session, principal, contact=contact, operation="restored", previous=previous, ) return contact def list_contact_quality_decisions( session: Session, principal: ApiPrincipal, contact_id: str, *, include_ended: bool = True, ) -> list[ContactPointQualityDecision]: contact = get_visible_contact(session, principal, contact_id, include_deleted=True) query = session.query(ContactPointQualityDecision).filter( ContactPointQualityDecision.contact_id == contact.id ) if not include_ended: now = utcnow() query = query.filter( or_( ContactPointQualityDecision.effective_until.is_(None), ContactPointQualityDecision.effective_until > now, ) ) return query.order_by( ContactPointQualityDecision.effective_from.desc(), ContactPointQualityDecision.created_at.desc(), ContactPointQualityDecision.id.asc(), ).all() def create_contact_quality_decision( session: Session, principal: ApiPrincipal, contact_id: str, payload: ContactPointQualityDecisionCreateRequest, ) -> ContactPointQualityDecision: contact = get_visible_contact(session, principal, contact_id) _require_mutable_book(contact.address_book) _validate_quality_contact_point(contact, payload.channel, payload.contact_point_id) effective_from = payload.effective_from or utcnow() overlapping = ( session.query(ContactPointQualityDecision) .filter( ContactPointQualityDecision.contact_id == contact.id, ContactPointQualityDecision.channel == payload.channel, ContactPointQualityDecision.contact_point_id == payload.contact_point_id, ContactPointQualityDecision.effective_from <= effective_from, or_( ContactPointQualityDecision.effective_until.is_(None), ContactPointQualityDecision.effective_until > effective_from, ), ) .all() ) for decision in overlapping: decision.effective_until = effective_from decision = ContactPointQualityDecision( tenant_id=contact.tenant_id, contact_id=contact.id, channel=payload.channel, contact_point_id=payload.contact_point_id, state=payload.state, reason_code=payload.reason_code or f"addresses.quality.{payload.state}", reason=_trim(payload.reason), evidence_ref=_trim(payload.evidence_ref), effective_from=effective_from, created_by_account_id=_account_id(principal), metadata_=dict(payload.metadata or {}), ) session.add(decision) session.flush() _record_address_contact_change( session, principal, contact=contact, operation="quality_updated", ) return decision def current_contact_quality( contact: Contact, *, effective_at: datetime | None = None, ) -> dict[tuple[str, str | None], ContactPointQualityDecision]: at = _aware_time(effective_at or utcnow()) current: dict[tuple[str, str | None], ContactPointQualityDecision] = {} for decision in contact.quality_decisions: decision_from = _aware_time(decision.effective_from) decision_until = ( _aware_time(decision.effective_until) if decision.effective_until is not None else None ) if decision_from > at: continue if decision_until is not None and decision_until <= at: continue key = (decision.channel, decision.contact_point_id) existing = current.get(key) if existing is None or ( decision_from, _aware_time(decision.created_at), decision.id, ) > ( _aware_time(existing.effective_from), _aware_time(existing.created_at), existing.id, ): current[key] = decision return current def list_contact_field_provenance( session: Session, principal: ApiPrincipal, contact_id: str, *, current_only: bool = False, limit: int = 500, ) -> list[ContactFieldProvenance]: contact = get_visible_contact(session, principal, contact_id, include_deleted=True) query = session.query(ContactFieldProvenance).filter( ContactFieldProvenance.contact_id == contact.id ) if current_only: query = query.filter(ContactFieldProvenance.selected.is_(True)) return query.order_by( ContactFieldProvenance.selected.desc(), ContactFieldProvenance.field_path.asc(), ContactFieldProvenance.created_at.desc(), ).limit(max(1, min(limit, 2_000))).all() def suggest_duplicate_contacts( session: Session, principal: ApiPrincipal, *, address_book_id: str, contact_id: str | None = None, minimum_score: int = 40, limit: int = 100, scan_limit: int = 500, ) -> ContactDuplicateScan: get_visible_address_book(session, principal, address_book_id) bounded_scan = max(2, min(scan_limit, 500)) total = count_contacts( session, principal, address_book_id=address_book_id, ) contacts = list_contacts( session, principal, address_book_id=address_book_id, limit=bounded_scan, ) if contact_id and not any(item.id == contact_id for item in contacts): selected = get_visible_contact(session, principal, contact_id) if selected.address_book_id != address_book_id: raise AddressBookError("Duplicate scan contact must belong to the selected address book.") contacts = [selected, *contacts[:-1]] if contacts else [selected] feature_index: dict[tuple[str, str], list[Contact]] = {} feature_details: dict[tuple[str, str], ContactDuplicateFeature] = {} for contact in contacts: for key, feature in _duplicate_features(contact): feature_index.setdefault(key, []).append(contact) feature_details[key] = feature pair_features: dict[tuple[str, str], dict[str, ContactDuplicateFeature]] = {} contacts_by_id = {item.id: item for item in contacts} for key, matched in feature_index.items(): if len(matched) < 2: continue for left_index, left in enumerate(matched[:-1]): for right in matched[left_index + 1 :]: if contact_id and contact_id not in {left.id, right.id}: continue pair = tuple(sorted((left.id, right.id))) feature = feature_details[key] pair_features.setdefault(pair, {})[feature.code] = feature suggestions: list[ContactDuplicateSuggestion] = [] for pair, features_by_code in pair_features.items(): features = tuple( sorted( features_by_code.values(), key=lambda item: (-item.weight, item.code), ) ) score = min(100, sum(item.weight for item in features)) if score < max(1, min(minimum_score, 100)): continue suggestions.append( ContactDuplicateSuggestion( left=contacts_by_id[pair[0]], right=contacts_by_id[pair[1]], score=score, confidence="strong" if score >= 85 else "likely" if score >= 60 else "possible", features=features, ) ) suggestions.sort( key=lambda item: ( -item.score, item.left.display_name.casefold(), item.right.display_name.casefold(), item.left.id, item.right.id, ) ) bounded_limit = max(1, min(limit, 100)) return ContactDuplicateScan( suggestions=tuple(suggestions[:bounded_limit]), scanned_contacts=len(contacts), candidate_pairs=len(suggestions), truncated=total > len(contacts) or len(suggestions) > bounded_limit, ) def address_quality_summary( session: Session, principal: ApiPrincipal, *, address_book_id: str, correction_limit: int = 100, ) -> AddressQualitySummary: get_visible_address_book(session, principal, address_book_id) contacts = list_contacts( session, principal, address_book_id=address_book_id, limit=500, ) total = count_contacts(session, principal, address_book_id=address_book_id) points = sum( len(contact.emails) + len(contact.phones) + len(contact.postal_addresses) for contact in contacts ) quality_counts = { "valid": points, "invalid": 0, "returned": 0, "stale": 0, "undeliverable": 0, } corrections: list[AddressQualityCorrection] = [] for contact in contacts: for (channel, point_id), decision in current_contact_quality(contact).items(): quality_counts[decision.state] = quality_counts.get(decision.state, 0) + 1 if decision.state != "valid": quality_counts["valid"] = max(0, quality_counts["valid"] - 1) corrections.append( AddressQualityCorrection( contact_id=contact.id, display_name=contact.display_name, channel=channel, contact_point_id=point_id, state=decision.state, reason_code=decision.reason_code, reason=decision.reason, effective_from=decision.effective_from, ) ) corrections.sort( key=lambda item: ( item.state, item.display_name.casefold(), item.contact_point_id or "", ) ) duplicates = suggest_duplicate_contacts( session, principal, address_book_id=address_book_id, limit=100, ) bounded_limit = max(1, min(correction_limit, 500)) return AddressQualitySummary( contact_count=total, contact_point_count=points, quality_counts=quality_counts, duplicate_suggestion_count=duplicates.candidate_pairs, correction_count=len(corrections), corrections=tuple(corrections[:bounded_limit]), truncated=( total > len(contacts) or len(corrections) > bounded_limit or duplicates.truncated ), ) def merge_contacts( session: Session, principal: ApiPrincipal, payload: ContactMergeRequest, ) -> ContactMergeRecord: contact_ids = list(dict.fromkeys([payload.winner_contact_id, *payload.duplicate_contact_ids])) if len(contact_ids) < 2: raise AddressBookError("A merge requires one winner and at least one distinct duplicate.") contacts = [get_visible_contact(session, principal, item) for item in contact_ids] winner = contacts[0] losers = contacts[1:] if any(item.address_book_id != winner.address_book_id for item in losers): raise AddressBookError("Contacts can only be merged inside one address book.") _require_mutable_book(winner.address_book) if session.query(ContactRedirect).filter( ContactRedirect.source_contact_id.in_(contact_ids), ContactRedirect.ended_at.is_(None), ).first() is not None: raise AddressBookError("A contact in this merge already has an active redirect.") allowed_sources = set(contact_ids) unsupported_fields = set(payload.field_sources) - set(_MERGE_SCALAR_FIELDS) if unsupported_fields: raise AddressBookError( f"Unsupported merge field source: {sorted(unsupported_fields)[0]}." ) for field_name, source_id in payload.field_sources.items(): if source_id not in allowed_sources: raise AddressBookError( f"Merge source for {field_name} must be one of the merged contacts." ) before_payload = _merge_state_payload(session, contacts) record = ContactMergeRecord( id=new_uuid(), tenant_id=winner.tenant_id, address_book_id=winner.address_book_id, winner_contact_id=winner.id, loser_contact_ids=[item.id for item in losers], status="active", reason=payload.reason.strip(), survivorship={ "field_sources": dict(payload.field_sources), "contact_point_strategy": payload.contact_point_strategy, "source_precedence": list(payload.source_precedence), }, decisions=[], before_payload=before_payload, after_payload={}, before_hash=_evidence_hash(before_payload), after_hash="0" * 64, created_by_account_id=_account_id(principal), provenance={"module": "addresses", "reversible": True}, ) session.add(record) session.flush() decisions: list[dict[str, Any]] = [] winner_provenance = copy.deepcopy( winner.provenance if isinstance(winner.provenance, dict) else {} ) raw_field_sources = winner_provenance.get("field_sources") retained_field_sources = ( copy.deepcopy(raw_field_sources) if isinstance(raw_field_sources, dict) else {} ) for field_name in _MERGE_SCALAR_FIELDS: selected = _merge_field_source( field_name, contacts, explicit_source_id=payload.field_sources.get(field_name), source_precedence=payload.source_precedence, ) value = getattr(selected, field_name) if field_name == "display_name" and not _has_merge_value(value): selected = winner value = winner.display_name setattr(winner, field_name, copy.deepcopy(value)) retained_field_sources[field_name] = { "source_kind": selected.source_kind, "source_ref": selected.source_ref or f"addresses:contact:{selected.id}", "source_revision": selected.source_revision, "source_contact_id": selected.id, } decisions.append( { "field_path": field_name, "source_contact_id": selected.id, "source_kind": selected.source_kind, "reason_code": ( "addresses.merge.explicit_survivor" if field_name in payload.field_sources else "addresses.merge.precedence_survivor" if payload.source_precedence else "addresses.merge.non_empty_survivor" ), } ) winner_provenance["field_sources"] = retained_field_sources winner.provenance = winner_provenance if payload.contact_point_strategy == "union": winner.tags = _merge_tags(contacts) decisions.append( { "field_path": "tags", "source_contact_ids": contact_ids, "reason_code": "addresses.merge.union", } ) point_map = _merge_contact_points( winner, losers, strategy=payload.contact_point_strategy, merge_record_id=record.id, decisions=decisions, ) session.flush() _copy_merge_governance( session, winner=winner, losers=losers, point_map=point_map, merge_record_id=record.id, principal=principal, ) _redirect_address_list_entries( session, winner=winner, losers=losers, point_map=point_map, ) merged_at = utcnow() for loser in losers: loser.deleted_at = merged_at loser.updated_by_account_id = _account_id(principal) session.add( ContactRedirect( tenant_id=winner.tenant_id, source_contact_id=loser.id, target_contact_id=winner.id, merge_record_id=record.id, ) ) _record_address_contact_change( session, principal, contact=loser, operation="merged_redirect", merge_record_id=record.id, ) winner.updated_by_account_id = _account_id(principal) _record_address_contact_change( session, principal, contact=winner, operation="merged", merge_record_id=record.id, ) session.flush() after_payload = _merge_state_payload(session, contacts) record.decisions = decisions record.after_payload = after_payload record.after_hash = _evidence_hash(after_payload) return record def recover_contact_merge( session: Session, principal: ApiPrincipal, merge_id: str, payload: ContactMergeRecoveryRequest, *, action: str, ) -> ContactMergeRecord: if action not in {"undo", "split"}: raise AddressBookError("Unsupported merge recovery action.") record = _visible_merge_record(session, principal, merge_id) if record.status != "active": raise AddressBookError("This merge has already been recovered.") if payload.expected_after_hash != record.after_hash: raise AddressBookError("Merge recovery evidence does not match the recorded post-merge state.") ids = [record.winner_contact_id, *record.loser_contact_ids] contacts = [get_visible_contact(session, principal, item, include_deleted=True) for item in ids] current_payload = _merge_state_payload(session, contacts) if _evidence_hash(current_payload) != record.after_hash: raise AddressBookError( "Contacts or list memberships changed after this merge; reconcile those edits before recovery." ) _require_mutable_book(contacts[0].address_book) snapshots = { str(item["id"]): item for item in record.before_payload.get("contacts", []) if isinstance(item, dict) and item.get("id") } for contact in contacts: snapshot = snapshots.get(contact.id) if snapshot is None: raise AddressBookError("Merge recovery evidence is incomplete.") _restore_contact_evidence(session, contact, snapshot, merge_record_id=record.id) session.flush() for item in record.before_payload.get("address_list_entries", []): if not isinstance(item, dict) or not item.get("id"): continue entry = session.get(AddressListEntry, str(item["id"])) if entry is None: raise AddressBookError("An address-list membership needed for recovery no longer exists.") entry.contact_id = str(item["contact_id"]) entry.contact_email_id = str(item["contact_email_id"]) if item.get("contact_email_id") else None entry.contact_postal_address_id = ( str(item["contact_postal_address_id"]) if item.get("contact_postal_address_id") else None ) entry.target_kind = str(item["target_kind"]) entry.label = str(item["label"]) if item.get("label") else None entry.order_index = int(item.get("order_index") or 0) recovered_at = utcnow() redirects = session.query(ContactRedirect).filter( ContactRedirect.merge_record_id == record.id, ContactRedirect.ended_at.is_(None), ).all() for redirect in redirects: redirect.ended_at = recovered_at record.status = "split" if action == "split" else "undone" record.recovered_at = recovered_at record.recovered_by_account_id = _account_id(principal) record.recovery_action = action record.recovery_reason = payload.reason.strip() for contact in contacts: _record_address_contact_change( session, principal, contact=contact, operation=f"merge_{action}", merge_record_id=record.id, ) return record def list_contact_merges( session: Session, principal: ApiPrincipal, *, address_book_id: str | None = None, contact_id: str | None = None, limit: int = 100, ) -> list[ContactMergeRecord]: visible_book_ids = [item.id for item in list_address_books(session, principal, include_deleted=True)] if not visible_book_ids: return [] query = session.query(ContactMergeRecord).filter( ContactMergeRecord.address_book_id.in_(visible_book_ids) ) if address_book_id: get_visible_address_book(session, principal, address_book_id, include_deleted=True) query = query.filter(ContactMergeRecord.address_book_id == address_book_id) if contact_id: get_visible_contact(session, principal, contact_id, include_deleted=True) rows = query.order_by(ContactMergeRecord.created_at.desc()).limit(500).all() if contact_id: rows = [ item for item in rows if item.winner_contact_id == contact_id or contact_id in (item.loser_contact_ids or []) ] return rows[: max(1, min(limit, 500))] def resolve_contact_redirect( session: Session, principal: ApiPrincipal, contact_id: str, ) -> ContactRedirectResolution: get_visible_contact(session, principal, contact_id, include_deleted=True) current_id = contact_id chain: list[str] = [] merge_ids: list[str] = [] seen = {contact_id} for _ in range(20): redirect = ( session.query(ContactRedirect) .filter( ContactRedirect.source_contact_id == current_id, ContactRedirect.ended_at.is_(None), ) .order_by(ContactRedirect.created_at.desc()) .first() ) if redirect is None: break if redirect.target_contact_id in seen: raise AddressBookError("Contact redirect cycle detected.") current_id = redirect.target_contact_id seen.add(current_id) chain.append(current_id) merge_ids.append(redirect.merge_record_id) get_visible_contact(session, principal, current_id, include_deleted=True) return ContactRedirectResolution( requested_contact_id=contact_id, resolved_contact_id=current_id, redirected=current_id != contact_id, redirect_chain=tuple(chain), merge_record_ids=tuple(merge_ids), ) _MERGE_SCALAR_FIELDS = ( "display_name", "given_name", "family_name", "organization", "role_title", "note", ) def _validate_quality_contact_point( contact: Contact, channel: str, point_id: str | None, ) -> None: if point_id is None: return point_ids: set[str] if channel == "email": point_ids = {item.id for item in contact.emails} elif channel == "postal": point_ids = {item.id for item in contact.postal_addresses} elif channel in {"internal_mail", "portal"}: point_ids = set() else: point_ids = {item.id for item in contact.phones} if point_id not in point_ids: raise AddressBookError("The selected quality contact point does not belong to this contact and channel.") def _duplicate_features( contact: Contact, ) -> list[tuple[tuple[str, str], ContactDuplicateFeature]]: rows: list[tuple[tuple[str, str], ContactDuplicateFeature]] = [] for item in contact.emails: value = item.normalized_email or _normalize_email(item.email) if value: rows.append( (("email", value), ContactDuplicateFeature("email_exact", "Same email address", 90, item.email)) ) for item in contact.phones: value = item.normalized_phone or _normalize_phone(item.phone) if len(value.lstrip("+")) >= 6: rows.append( (("phone", value), ContactDuplicateFeature("phone_exact", "Same phone number", 80, item.phone)) ) for item in contact.postal_addresses: value = _postal_match_key(item) if value: rows.append( (("postal", value), ContactDuplicateFeature("postal_exact", "Same postal address", 65, _postal_display_value(item))) ) name = _normalize_match_text(contact.display_name) organization = _normalize_match_text(contact.organization) if name and organization: rows.append( (("name_org", f"{name}|{organization}"), ContactDuplicateFeature("name_organization_exact", "Same name and organization", 55, f"{contact.display_name} ยท {contact.organization}")) ) elif name: rows.append( (("name", name), ContactDuplicateFeature("name_exact", "Same display name", 35, contact.display_name)) ) return rows def _postal_match_key(item: ContactPostalAddress) -> str: normalized = dict(item.normalized_value or {}) values = [ str(normalized.get(key) or _normalize_match_text(getattr(item, key))) for key in ("street", "postal_code", "locality", "region", "country") ] return "|".join(values) if any(values) else "" def _postal_display_value(item: ContactPostalAddress) -> str: return ", ".join( value for value in ( item.street, " ".join(part for part in (item.postal_code, item.locality) if part), item.region, item.country, ) if value ) def _has_merge_value(value: Any) -> bool: return value not in (None, "", [], {}) def _merge_field_source( field_name: str, contacts: list[Contact], *, explicit_source_id: str | None, source_precedence: list[str], ) -> Contact: if explicit_source_id: return next(item for item in contacts if item.id == explicit_source_id) available = [item for item in contacts if _has_merge_value(getattr(item, field_name))] if not available: return contacts[0] if not source_precedence: return available[0] ranks = {kind: len(source_precedence) - index for index, kind in enumerate(source_precedence)} return max( available, key=lambda item: ( ranks.get(item.source_kind, 0), _source_precedence(item.source_kind), item.id == contacts[0].id, ), ) def _merge_tags(contacts: list[Contact]) -> list[str]: seen: set[str] = set() result: list[str] = [] for contact in contacts: for value in contact.tags or []: key = value.casefold() if key not in seen: seen.add(key) result.append(value) return result def _merge_contact_points( winner: Contact, losers: list[Contact], *, strategy: str, merge_record_id: str, decisions: list[dict[str, Any]], ) -> dict[str, str]: point_map: dict[str, str] = {} if strategy == "winner_only": return point_map email_keys = { item.normalized_email or _normalize_email(item.email): item for item in winner.emails if item.normalized_email or _normalize_email(item.email) } phone_keys = { item.normalized_phone or _normalize_phone(item.phone): item for item in winner.phones if item.normalized_phone or _normalize_phone(item.phone) } postal_keys = { _postal_match_key(item): item for item in winner.postal_addresses if _postal_match_key(item) } for loser in losers: for item in loser.emails: key = item.normalized_email or _normalize_email(item.email) existing = email_keys.get(key) created = existing is None if existing is None: existing = ContactEmail( id=new_uuid(), label=item.label, email=item.email, original_email=item.original_email or item.email, normalized_email=key, provenance={ **dict(item.provenance or {}), "merge_record_id": merge_record_id, "copied_from_contact_id": loser.id, "copied_from_contact_point_id": item.id, }, is_primary=item.is_primary and not any(row.is_primary for row in winner.emails), order_index=len(winner.emails), ) winner.emails.append(existing) email_keys[key] = existing point_map[item.id] = existing.id decisions.append(_contact_point_merge_decision("email", item.id, existing.id, loser.id, created)) for item in loser.phones: key = item.normalized_phone or _normalize_phone(item.phone) existing = phone_keys.get(key) created = existing is None if existing is None: existing = ContactPhone( id=new_uuid(), label=item.label, phone=item.phone, original_phone=item.original_phone or item.phone, normalized_phone=key, provenance={ **dict(item.provenance or {}), "merge_record_id": merge_record_id, "copied_from_contact_id": loser.id, "copied_from_contact_point_id": item.id, }, is_primary=item.is_primary and not any(row.is_primary for row in winner.phones), order_index=len(winner.phones), ) winner.phones.append(existing) phone_keys[key] = existing point_map[item.id] = existing.id decisions.append(_contact_point_merge_decision("phone", item.id, existing.id, loser.id, created)) for item in loser.postal_addresses: key = _postal_match_key(item) existing = postal_keys.get(key) created = existing is None if existing is None: existing = ContactPostalAddress( id=new_uuid(), label=item.label, street=item.street, postal_code=item.postal_code, locality=item.locality, region=item.region, country=item.country, original_value=copy.deepcopy(item.original_value or {}), normalized_value=copy.deepcopy(item.normalized_value or {}), provenance={ **dict(item.provenance or {}), "merge_record_id": merge_record_id, "copied_from_contact_id": loser.id, "copied_from_contact_point_id": item.id, }, is_primary=item.is_primary and not any(row.is_primary for row in winner.postal_addresses), order_index=len(winner.postal_addresses), ) winner.postal_addresses.append(existing) postal_keys[key] = existing point_map[item.id] = existing.id decisions.append(_contact_point_merge_decision("postal", item.id, existing.id, loser.id, created)) return point_map def _contact_point_merge_decision( channel: str, source_id: str, selected_id: str, source_contact_id: str, created: bool, ) -> dict[str, Any]: return { "field_path": f"{channel}.{selected_id}", "source_contact_id": source_contact_id, "source_contact_point_id": source_id, "selected_contact_point_id": selected_id, "reason_code": "addresses.merge.union" if created else "addresses.merge.normalized_duplicate", } def _copy_merge_governance( session: Session, *, winner: Contact, losers: list[Contact], point_map: dict[str, str], merge_record_id: str, principal: ApiPrincipal, ) -> None: now = _aware_time(utcnow()) for loser in losers: for rule in loser.channel_rules: if rule.contact_point_id is not None and rule.contact_point_id not in point_map: continue winner.channel_rules.append( ContactChannelRule( tenant_id=winner.tenant_id, channel=rule.channel, purpose=rule.purpose, contact_point_id=point_map.get(rule.contact_point_id or ""), decision=rule.decision, legal_basis=rule.legal_basis, evidence_ref=rule.evidence_ref, reason=rule.reason, preference_rank=rule.preference_rank, locale=rule.locale, effective_from=rule.effective_from, effective_until=rule.effective_until, created_by_account_id=_account_id(principal), metadata_={ **dict(rule.metadata_ or {}), "merge_record_id": merge_record_id, "copied_from_contact_id": loser.id, "copied_from_rule_id": rule.id, }, ) ) for decision in loser.quality_decisions: if ( decision.contact_point_id is not None and decision.contact_point_id not in point_map ): continue if _aware_time(decision.effective_from) > now or ( decision.effective_until is not None and _aware_time(decision.effective_until) <= now ): continue winner.quality_decisions.append( ContactPointQualityDecision( tenant_id=winner.tenant_id, channel=decision.channel, contact_point_id=point_map.get(decision.contact_point_id or ""), state=decision.state, reason_code=decision.reason_code, reason=decision.reason, evidence_ref=decision.evidence_ref, effective_from=decision.effective_from, effective_until=decision.effective_until, created_by_account_id=_account_id(principal), metadata_={ **dict(decision.metadata_ or {}), "merge_record_id": merge_record_id, "copied_from_contact_id": loser.id, "copied_from_quality_decision_id": decision.id, }, ) ) def _redirect_address_list_entries( session: Session, *, winner: Contact, losers: list[Contact], point_map: dict[str, str], ) -> None: loser_ids = [item.id for item in losers] entries = session.query(AddressListEntry).filter( AddressListEntry.contact_id.in_(loser_ids) ).all() for entry in entries: entry.contact_id = winner.id if entry.contact_email_id: entry.contact_email_id = point_map.get(entry.contact_email_id) if entry.contact_postal_address_id: entry.contact_postal_address_id = point_map.get(entry.contact_postal_address_id) entry.target_kind = _address_list_entry_kind( next((item for item in winner.emails if item.id == entry.contact_email_id), None), next((item for item in winner.postal_addresses if item.id == entry.contact_postal_address_id), None), ) def _contact_evidence_payload(contact: Contact) -> dict[str, Any]: return { "id": contact.id, "tenant_id": contact.tenant_id, "address_book_id": contact.address_book_id, "display_name": contact.display_name, "given_name": contact.given_name, "family_name": contact.family_name, "organization": contact.organization, "role_title": contact.role_title, "note": contact.note, "tags": list(contact.tags or []), "source_kind": contact.source_kind, "source_ref": contact.source_ref, "source_payload_kind": contact.source_payload_kind, "source_payload_raw": contact.source_payload_raw, "source_revision": contact.source_revision, "provenance": copy.deepcopy(contact.provenance or {}), "metadata": copy.deepcopy(contact.metadata_ or {}), "created_by_account_id": contact.created_by_account_id, "updated_by_account_id": contact.updated_by_account_id, "deleted_at": _evidence_datetime(contact.deleted_at), "emails": [ { "id": item.id, "label": item.label, "email": item.email, "original_email": item.original_email, "normalized_email": item.normalized_email, "provenance": copy.deepcopy(item.provenance or {}), "is_primary": item.is_primary, "order_index": item.order_index, } for item in contact.emails ], "phones": [ { "id": item.id, "label": item.label, "phone": item.phone, "original_phone": item.original_phone, "normalized_phone": item.normalized_phone, "provenance": copy.deepcopy(item.provenance or {}), "is_primary": item.is_primary, "order_index": item.order_index, } for item in contact.phones ], "postal_addresses": [ { "id": item.id, "label": item.label, "street": item.street, "postal_code": item.postal_code, "locality": item.locality, "region": item.region, "country": item.country, "original_value": copy.deepcopy(item.original_value or {}), "normalized_value": copy.deepcopy(item.normalized_value or {}), "provenance": copy.deepcopy(item.provenance or {}), "is_primary": item.is_primary, "order_index": item.order_index, } for item in contact.postal_addresses ], "channel_rules": [ { "id": item.id, "channel": item.channel, "purpose": item.purpose, "contact_point_id": item.contact_point_id, "decision": item.decision, "legal_basis": item.legal_basis, "evidence_ref": item.evidence_ref, "reason": item.reason, "preference_rank": item.preference_rank, "locale": item.locale, "effective_from": _evidence_datetime(item.effective_from), "effective_until": _evidence_datetime(item.effective_until), "metadata": copy.deepcopy(item.metadata_ or {}), } for item in contact.channel_rules ], "quality_decisions": [ { "id": item.id, "channel": item.channel, "contact_point_id": item.contact_point_id, "state": item.state, "reason_code": item.reason_code, "reason": item.reason, "evidence_ref": item.evidence_ref, "effective_from": _evidence_datetime(item.effective_from), "effective_until": _evidence_datetime(item.effective_until), "metadata": copy.deepcopy(item.metadata_ or {}), } for item in contact.quality_decisions ], } def _address_list_entry_evidence(entry: AddressListEntry) -> dict[str, Any]: return { "id": entry.id, "address_list_id": entry.address_list_id, "contact_id": entry.contact_id, "contact_email_id": entry.contact_email_id, "contact_postal_address_id": entry.contact_postal_address_id, "target_kind": entry.target_kind, "label": entry.label, "order_index": entry.order_index, } def _merge_state_payload(session: Session, contacts: list[Contact]) -> dict[str, Any]: ids = [item.id for item in contacts] session.flush() entries = session.query(AddressListEntry).filter(AddressListEntry.contact_id.in_(ids)).all() return { "contacts": [ _contact_evidence_payload(item) for item in sorted(contacts, key=lambda row: row.id) ], "address_list_entries": [ _address_list_entry_evidence(item) for item in sorted(entries, key=lambda row: row.id) ], } def _evidence_hash(payload: dict[str, Any]) -> str: return hashlib.sha256( json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") ).hexdigest() def _evidence_datetime(value: datetime | None) -> str | None: if value is None: return None return _aware_time(value).astimezone(UTC).isoformat() def _visible_merge_record( session: Session, principal: ApiPrincipal, merge_id: str, ) -> ContactMergeRecord: record = session.get(ContactMergeRecord, merge_id) if record is None: raise AddressBookError("Contact merge record not found.") get_visible_address_book(session, principal, record.address_book_id, include_deleted=True) return record def _restore_contact_evidence( session: Session, contact: Contact, snapshot: dict[str, Any], *, merge_record_id: str, ) -> None: for field_name in ( "display_name", "given_name", "family_name", "organization", "role_title", "note", "source_kind", "source_ref", "source_payload_kind", "source_payload_raw", "source_revision", "created_by_account_id", "updated_by_account_id", ): setattr(contact, field_name, snapshot.get(field_name)) contact.tags = list(snapshot.get("tags") or []) contact.provenance = copy.deepcopy(snapshot.get("provenance") or {}) contact.metadata_ = copy.deepcopy(snapshot.get("metadata") or {}) contact.deleted_at = ( datetime.fromisoformat(str(snapshot["deleted_at"])) if snapshot.get("deleted_at") else None ) for rule in list(contact.channel_rules): if (rule.metadata_ or {}).get("merge_record_id") == merge_record_id: session.delete(rule) for decision in list(contact.quality_decisions): if (decision.metadata_ or {}).get("merge_record_id") == merge_record_id: session.delete(decision) contact.emails.clear() contact.phones.clear() contact.postal_addresses.clear() session.flush() contact.emails.extend( ContactEmail( id=str(item["id"]), label=item.get("label"), email=str(item.get("email") or ""), original_email=str(item.get("original_email") or item.get("email") or ""), normalized_email=str(item.get("normalized_email") or ""), provenance=copy.deepcopy(item.get("provenance") or {}), is_primary=bool(item.get("is_primary")), order_index=int(item.get("order_index") or 0), ) for item in snapshot.get("emails", []) if isinstance(item, dict) and item.get("id") ) contact.phones.extend( ContactPhone( id=str(item["id"]), label=item.get("label"), phone=str(item.get("phone") or ""), original_phone=str(item.get("original_phone") or item.get("phone") or ""), normalized_phone=str(item.get("normalized_phone") or ""), provenance=copy.deepcopy(item.get("provenance") or {}), is_primary=bool(item.get("is_primary")), order_index=int(item.get("order_index") or 0), ) for item in snapshot.get("phones", []) if isinstance(item, dict) and item.get("id") ) contact.postal_addresses.extend( ContactPostalAddress( id=str(item["id"]), label=item.get("label"), street=item.get("street"), postal_code=item.get("postal_code"), locality=item.get("locality"), region=item.get("region"), country=item.get("country"), original_value=copy.deepcopy(item.get("original_value") or {}), normalized_value=copy.deepcopy(item.get("normalized_value") or {}), provenance=copy.deepcopy(item.get("provenance") or {}), is_primary=bool(item.get("is_primary")), order_index=int(item.get("order_index") or 0), ) for item in snapshot.get("postal_addresses", []) if isinstance(item, dict) and item.get("id") ) def _aware_time(value: datetime) -> datetime: return value if value.tzinfo is not None else value.replace(tzinfo=UTC) def export_address_book_vcard(session: Session, principal: ApiPrincipal, address_book_id: str) -> tuple[AddressBook, str]: book = get_visible_address_book(session, principal, address_book_id) contacts = ( session.query(Contact) .filter(Contact.address_book_id == book.id, Contact.deleted_at.is_(None)) .order_by(Contact.display_name.asc(), Contact.id.asc()) .all() ) return book, contacts_to_vcard(contacts) def export_contact_vcard(session: Session, principal: ApiPrincipal, contact_id: str) -> tuple[Contact, str]: contact = get_visible_contact(session, principal, contact_id) return contact, contacts_to_vcard([contact])