Files
govoplan-addresses/src/govoplan_addresses/backend/service.py
T

4630 lines
171 KiB
Python

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])