From 8099e34c6cadf603b381b384eb4d1b2398b8e22e Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sun, 2 Aug 2026 07:24:29 +0200 Subject: [PATCH] Integrate governed address contact resolution --- README.md | 4 +- docs/ADREMA_CAPABILITY_ASSESSMENT.md | 10 +- docs/DISTRIBUTION_LISTS_ARCHITECTURE.md | 16 +- docs/IMPLEMENTATION_PLAN.md | 6 +- src/govoplan_dist_lists/backend/expansion.py | 398 +++++++++++++++++- src/govoplan_dist_lists/backend/manifest.py | 37 ++ tests/test_manifest.py | 11 + tests/test_service.py | 250 +++++++++++ .../DistributionListsPage.tsx | 54 +++ 9 files changed, 774 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 67dbbbf..be22f3a 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ Distribution Lists owns: - German-administration `Verteiler` semantics - static, parameterized, and Dataflow-backed dynamic audience definitions - expansion of mixed entries into concrete delivery/recipient targets -- snapshot DTOs with source revision and provenance +- purpose-aware Addresses contact-point resolution for email and postal targets +- snapshot DTOs with exact rendered targets, contact-point IDs, source revision, + fingerprint, and decision provenance - stale-source detection for expanded entries - eligibility and policy explanations for disabled recipient entries - audit evidence for who was resolved at execution time diff --git a/docs/ADREMA_CAPABILITY_ASSESSMENT.md b/docs/ADREMA_CAPABILITY_ASSESSMENT.md index b68c81f..a5cb077 100644 --- a/docs/ADREMA_CAPABILITY_ASSESSMENT.md +++ b/docs/ADREMA_CAPABILITY_ASSESSMENT.md @@ -180,7 +180,7 @@ typed output template, and Report/Campaign run configuration. | Multiple email, phone, and postal contact points | Implemented | Addresses | | Static address lists | Implemented | Addresses | | vCard import/export and CardDAV sync | Implemented | Addresses | -| Read-only lookup and immutable email-recipient snapshots | Implemented | Addresses | +| Read-only lookup and immutable email/postal recipient snapshots | Implemented | Addresses/Distribution Lists | | Source provenance and sync conflicts | Implemented | Addresses | | Organization units and function definitions | Implemented | Organizations | | Effective-dated identity-to-function assignments | Implemented first slice | IDM | @@ -190,10 +190,10 @@ typed output template, and Report/Campaign run configuration. | Campaign snapshots, review, digital delivery, and evidence | Implemented first channel | Campaign | | Typed template library and document rendering | Scaffold/issue only | Templates | | BI reports and spreadsheet-grade exports | Scaffold/issue only | Reporting | -| Mixed operational distribution lists and governed segments | Contract/implementation issues only | Distribution Lists | -| Contact-point consent, suppression, and preferences | Open issue | Addresses/Policy | -| Deduplication, merge, quality, and complete history | Open issue | Addresses | -| Channel-neutral expansion and frozen postal targets | Missing | Distribution Lists/Addresses | +| Mixed operational distribution lists and governed segments | Implemented broad first slice | Distribution Lists | +| Contact-point consent, suppression, and preferences | Implemented | Addresses/Policy | +| Deduplication, merge, quality, and complete history | Implemented first slice | Addresses | +| Channel-neutral expansion and frozen postal targets | Implemented | Distribution Lists/Addresses | | Printable label, envelope, and letter output | Open issue | Templates | | End-to-end guided AdreMa workflow | Missing | Workflow plus consuming modules | diff --git a/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md b/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md index 783d1a9..52cfc46 100644 --- a/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md +++ b/docs/DISTRIBUTION_LISTS_ARCHITECTURE.md @@ -83,6 +83,16 @@ The first capabilities should be: - `dist_lists.expand`: expand a distribution list into immutable recipient rows. - `dist_lists.writer`: explain whether a caller can create or change a list. +When `addresses.contact_point_resolution` is available, address-contact and +address-list entries are resolved through that versioned contract. Entry and +expansion channel constraints are intersected, and purpose, effective time, +address purpose, fallback rule, locale, and postal format are passed to the +provider. Selected and rejected contact points retain stable reason codes and +provider provenance. The frozen Distribution Lists snapshot stores the exact +rendered target and contact-point ID, so later address changes do not rewrite +delivery evidence. Legacy Addresses lookup/email-source capabilities are used +only as a compatibility fallback. + Consumers must resolve those capabilities through core and must not import distribution-list ORM or service internals. @@ -98,9 +108,9 @@ Likely consumers: ## Relationship To Address Lists -`govoplan-addresses` should implement classical address lists first. A -distribution list may later include an address list as an entry, but the -address module remains the owner of contact records and address-only grouping. +`govoplan-addresses` implements classical address lists. A distribution list +may include an address list as an entry, but the address module remains the +owner of contact records and address-only grouping. The distribution-list module owns mixed operational routing. This prevents campaign-specific recipient logic from becoming the platform's de facto diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 55db05e..2f2db25 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -44,13 +44,17 @@ Goal: allow distribution lists to resolve through installed modules. Tasks: -- [x] integrate with `addresses.lookup` and address-list source capability +- [x] integrate with versioned, channel-neutral Addresses contact-point + resolution; retain lookup/address-list source capabilities as compatibility + fallbacks - [x] integrate with IDM identity and effective function-assignment resolution - [ ] resolve typed IDM groups once `govoplan-idm#7` exposes the provider contract; until then the selector and expansion return an explicit unavailable outcome - [x] integrate with Organizations unit/function resolution - [x] consume governed Datasource/Connector states through pinned Dataflow outputs - [x] apply Addresses preference/consent facts and explainable Policy decisions +- [x] freeze exact postal/email targets, contact-point IDs, revisions, + fingerprints, and decision provenance in expansion snapshots - [x] preserve module independence when optional providers are absent ## Milestone 4: UI diff --git a/src/govoplan_dist_lists/backend/expansion.py b/src/govoplan_dist_lists/backend/expansion.py index 24affe3..fc35a51 100644 --- a/src/govoplan_dist_lists/backend/expansion.py +++ b/src/govoplan_dist_lists/backend/expansion.py @@ -16,6 +16,14 @@ from govoplan_core.core.dataflows import ( DataflowDatasetRequest, dataflow_dataset_output, ) +from govoplan_core.core.contact_points import ( + CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, + ContactPointCandidate, + ContactPointResolution, + ContactPointResolutionProvider, + ContactPointResolutionRequest, + ContactPointSourceRequest, +) from govoplan_core.core.distribution_lists import ( CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, CAPABILITY_RECIPIENT_CHANNEL_FACTS, @@ -566,6 +574,199 @@ def _nested_list_recipients( def _address_recipients( context: _ExpansionContext, entry: DistributionListEntryRef, +) -> list[DistributionRecipientRef]: + provider = _typed_capability( + context.registry, + CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, + ContactPointResolutionProvider, + ) + if provider is not None: + return _contact_point_recipients(context, entry, provider) + return _legacy_address_recipients(context, entry) + + +def _contact_point_recipients( + context: _ExpansionContext, + entry: DistributionListEntryRef, + provider: ContactPointResolutionProvider, +) -> list[DistributionRecipientRef]: + requested_channels, constrained = _requested_channel_filter(context, entry) + if constrained and not requested_channels: + return [ + _unresolved( + entry, + "channel.no_common_selection", + "The entry and expansion request do not allow a common delivery channel.", + status="suppressed", + ) + ] + if entry.kind == "address_list": + return _contact_point_source_recipients( + context, + entry, + provider, + requested_channels=requested_channels, + ) + try: + resolution = provider.resolve_contact_points( + context.session, + context.principal, + request=ContactPointResolutionRequest( + tenant_id=context.principal.tenant_id, + subject=entry.source, + effective_at=context.effective_at, + purpose=entry.purpose or context.request.purpose, + requested_channels=requested_channels, + address_purpose=_text(entry.configuration.get("address_purpose")), + fallback_rule=_contact_point_fallback_rule(entry), + locale=_text(entry.configuration.get("locale")), + postal_format=_postal_format(entry), + context={ + "distribution_list_id": context.request.list_id, + "distribution_list_entry_id": entry.id, + }, + ), + ) + except (LookupError, PermissionError, ValueError) as exc: + return [_unresolved(entry, "addresses.contact_point_failed", str(exc))] + contact_revision = _contact_record_revision(resolution) + stale = _is_stale( + entry.source, + actual_revision=contact_revision, + actual_fingerprint=resolution.source_fingerprint, + ) + context.evidence.append( + _contact_point_evidence( + entry, + actual_revision=contact_revision or resolution.source_revision, + actual_fingerprint=resolution.source_fingerprint, + stale=stale, + details={ + "contract_version": resolution.contract_version, + "contact_id": resolution.contact_id, + "contact_point_revision": resolution.source_revision, + "contact_point_fingerprint": resolution.source_fingerprint, + "provenance": dict(resolution.provenance), + }, + ) + ) + return [ + _contact_point_recipient( + entry, + resolution, + stale=stale, + expected_email=( + _text(entry.configuration.get("email")) + or _text(entry.source.metadata.get("email")) + if entry.kind == "address_email" + else None + ), + ) + ] + + +def _contact_point_source_recipients( + context: _ExpansionContext, + entry: DistributionListEntryRef, + provider: ContactPointResolutionProvider, + *, + requested_channels: tuple[str, ...], +) -> list[DistributionRecipientRef]: + source_id = _contact_point_source_id(entry) + source_request = ContactPointSourceRequest( + tenant_id=context.principal.tenant_id, + source_id=source_id, + effective_at=context.effective_at, + purpose=entry.purpose or context.request.purpose, + requested_channels=requested_channels, # type: ignore[arg-type] + address_purpose=_text(entry.configuration.get("address_purpose")), + fallback_rule=_contact_point_fallback_rule(entry), + locale=_text(entry.configuration.get("locale")), + postal_format=_postal_format(entry), + max_items=20_000, + context={ + "distribution_list_id": context.request.list_id, + "distribution_list_entry_id": entry.id, + }, + ) + resolutions: list[ContactPointResolution] = [] + preview = None + source_revision: str | None = None + source_fingerprint: str | None = None + offset = 0 + provider_limit = context.request.limits.max_provider_results + try: + while len(resolutions) < provider_limit: + page_limit = min(500, provider_limit - len(resolutions)) + preview = provider.preview_source( + context.session, + context.principal, + request=source_request, + offset=offset, + limit=page_limit, + ) + if source_revision is None: + source_revision = preview.source_revision + source_fingerprint = preview.source_fingerprint + elif ( + preview.source_revision != source_revision + or preview.source_fingerprint != source_fingerprint + ): + return [ + _unresolved( + entry, + "addresses.source_changed", + "The Addresses source changed while it was being expanded; retry the expansion.", + status="stale", + ) + ] + resolutions.extend(preview.resolutions) + offset += len(preview.resolutions) + if not preview.has_more or not preview.resolutions: + break + except (LookupError, PermissionError, ValueError) as exc: + return [_unresolved(entry, "addresses.source_failed", str(exc))] + if preview is None: + return [] + stale = _is_stale( + entry.source, + actual_revision=source_revision, + actual_fingerprint=source_fingerprint, + ) + context.evidence.append( + _contact_point_evidence( + entry, + actual_revision=source_revision, + actual_fingerprint=source_fingerprint, + stale=stale, + generated_at=preview.generated_at, + details={ + "contract_version": preview.contract_version, + "source_id": source_id, + "total_count": preview.total_count, + "provenance": dict(preview.provenance), + }, + ) + ) + if preview.total_count > len(resolutions): + context.candidate_truncated = True + context.diagnostics.append( + _explanation( + "provider.result_limit", + "Addresses results were truncated by the provider limit.", + provider="addresses", + source=entry.source, + ) + ) + return [ + _contact_point_recipient(entry, resolution, stale=stale) + for resolution in resolutions + ] + + +def _legacy_address_recipients( + context: _ExpansionContext, + entry: DistributionListEntryRef, ) -> list[DistributionRecipientRef]: if entry.kind == "address_list": provider = _capability(context.registry, ADDRESSES_RECIPIENT_SOURCE) @@ -631,6 +832,194 @@ def _address_recipients( return [_address_lookup_recipient(entry, item) for item in exact] +def _contact_point_recipient( + entry: DistributionListEntryRef, + resolution: ContactPointResolution, + *, + stale: bool, + expected_email: str | None = None, +) -> DistributionRecipientRef: + accepted = list(resolution.candidates) + rejected = list(resolution.excluded) + if expected_email is not None: + matching: list[ContactPointCandidate] = [] + for candidate in accepted: + if candidate.channel == "email" and candidate.target.casefold() == expected_email.casefold(): + matching.append(candidate) + else: + rejected.append( + replace( + candidate, + status="suppressed", + reason_code="addresses.email.not_selected", + explanation="This contact point is not the email address selected by the list entry.", + ) + ) + accepted = matching + channels = tuple( + _distribution_channel_candidate(item, entry=entry, stale=stale) + for item in (*accepted, *rejected) + ) + selected_channels = channels[: len(accepted)] + if any(item.status == "usable" for item in selected_channels): + status = "usable" + elif any(item.status == "stale" for item in selected_channels): + status = "stale" + else: + status = _recipient_outcome(channels, resolution.status) + explanations = [*resolution.explanations] + explanations.extend( + DistributionExplanation( + code=item.reason_code, + message=item.explanation, + severity="warning", + provider="addresses", + source=item.source or entry.source, + provenance=dict(item.provenance), + ) + for item in rejected + if item.reason_code and item.explanation + ) + contact_id = resolution.contact_id + return DistributionRecipientRef( + recipient_key=f"contact:{contact_id}" if contact_id else f"unresolved:{entry.id}", + display_name=( + resolution.display_name + or entry.label + or entry.source.label + or contact_id + or entry.source.resource_id + ), + status=status, # type: ignore[arg-type] + channels=channels, + contact_id=contact_id, + source_entry_ids=(entry.id,), + explanations=tuple(_unique_explanations(explanations)), + attributes={ + "contact_points": [ + { + "channel": item.channel, + "contact_point_id": item.contact_point_id, + "address_purpose": item.address_purpose, + "value": dict(item.value), + } + for item in (*accepted, *rejected) + ], + }, + provenance={ + **_entry_provenance(entry), + "contact_point_resolution": { + "contract_version": resolution.contract_version, + "source_revision": resolution.source_revision, + "source_fingerprint": resolution.source_fingerprint, + "provenance": dict(resolution.provenance), + }, + "channel_facts_resolved": True, + }, + ) + + +def _distribution_channel_candidate( + candidate: ContactPointCandidate, + *, + entry: DistributionListEntryRef, + stale: bool, +) -> DistributionChannelCandidate: + status = candidate.status + reason_code = candidate.reason_code + explanation = candidate.explanation + if stale and status == "usable": + status = "stale" + reason_code = "source.stale" + explanation = "The source revision changed." + return DistributionChannelCandidate( + channel=candidate.channel, + target=candidate.target, + target_key=candidate.target_key, + status=status, + contact_point_id=candidate.contact_point_id, + locale=candidate.locale, + preferred=candidate.preferred, + reason_code=reason_code, + explanation=explanation, + source=candidate.source or entry.source, + decision_provenance={ + **dict(candidate.provenance), + "address_purpose": candidate.address_purpose, + "preference_rank": candidate.preference_rank, + "source_revision": candidate.source_revision, + "preference_revision": candidate.preference_revision, + "consent_revision": candidate.consent_revision, + "value": dict(candidate.value), + }, + ) + + +def _contact_point_evidence( + entry: DistributionListEntryRef, + *, + actual_revision: str | None, + actual_fingerprint: str | None, + stale: bool, + details: Mapping[str, object], + generated_at: datetime | None = None, +) -> DistributionProviderEvidence: + return DistributionProviderEvidence( + provider="addresses", + source=entry.source, + actual_revision=actual_revision, + actual_fingerprint=actual_fingerprint, + stale=stale, + generated_at=generated_at, + details=details, + ) + + +def _contact_record_revision(resolution: ContactPointResolution) -> str | None: + revisions = { + str(revision).strip() + for candidate in (*resolution.candidates, *resolution.excluded) + if (revision := candidate.provenance.get("source_revision")) is not None + and str(revision).strip() + } + return next(iter(revisions)) if len(revisions) == 1 else None + + +def _contact_point_source_id(entry: DistributionListEntryRef) -> str: + resource_id = entry.source.resource_id + if resource_id.startswith("addresses:"): + return resource_id + resource_type = entry.source.resource_type + if resource_type in {"address_book", "book"}: + return f"addresses:address_book:{resource_id}" + return f"addresses:address_list:{resource_id}" + + +def _contact_point_fallback_rule(entry: DistributionListEntryRef): + value = _text(entry.configuration.get("fallback_rule")) or "primary" + return value if value in {"none", "primary", "any"} else "primary" + + +def _postal_format(entry: DistributionListEntryRef): + value = _text(entry.configuration.get("postal_format")) or "domestic" + return value if value in {"domestic", "international"} else "domestic" + + +def _requested_channel_filter( + context: _ExpansionContext, + entry: DistributionListEntryRef, +) -> tuple[tuple[str, ...], bool]: + entry_channels = set(entry.requested_channels) + if entry.kind == "address_email": + entry_channels = {"email"} + caller_channels = set(context.request.requested_channels) + if entry_channels and caller_channels: + selected = entry_channels.intersection(caller_channels) + else: + selected = entry_channels or caller_channels + return tuple(sorted(selected)), bool(entry_channels or caller_channels) + + def _address_snapshot_recipient( entry: DistributionListEntryRef, item: object, @@ -1063,7 +1452,11 @@ def _apply_channel_decisions( CAPABILITY_RECIPIENT_CHANNEL_FACTS, RecipientChannelFactsProvider, ) - if facts_provider is not None and recipient.source_entry_ids: + if ( + facts_provider is not None + and recipient.source_entry_ids + and not recipient.provenance.get("channel_facts_resolved") + ): source = channels[0].source if channels else None if source is not None: facts = facts_provider.resolve_channel_facts( @@ -1097,12 +1490,13 @@ def _apply_channel_decisions( else set() ) requested_by_caller = set(context.request.requested_channels) + has_channel_constraint = bool(requested_by_entry or requested_by_caller) requested_channels = ( requested_by_entry.intersection(requested_by_caller) if requested_by_entry and requested_by_caller else requested_by_entry or requested_by_caller ) - if requested_channels: + if has_channel_constraint: channels = [ item if item.channel in requested_channels diff --git a/src/govoplan_dist_lists/backend/manifest.py b/src/govoplan_dist_lists/backend/manifest.py index 9e0614f..12e3f6a 100644 --- a/src/govoplan_dist_lists/backend/manifest.py +++ b/src/govoplan_dist_lists/backend/manifest.py @@ -7,6 +7,9 @@ from govoplan_core.core.access import ( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, ) from govoplan_core.core.dataflows import CAPABILITY_DATAFLOW_DATASET_OUTPUT +from govoplan_core.core.contact_points import ( + CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, +) from govoplan_core.core.distribution_lists import ( CAPABILITY_DISTRIBUTION_LIST_EXPAND, CAPABILITY_DISTRIBUTION_LIST_SOURCE, @@ -30,6 +33,7 @@ from govoplan_core.core.modules import ( MigrationSpec, ModuleContext, ModuleInterfaceProvider, + ModuleInterfaceRequirement, ModuleManifest, NavItem, PermissionDefinition, @@ -95,6 +99,24 @@ DOCUMENTATION = ( related_modules=("addresses", "campaigns", "mail", "postbox", "notifications", "scheduling", "poll", "workflow_engine", "tasks"), metadata={"seed": True}, ), + DocumentationTopic( + id=f"{MODULE_ID}.address-contact-resolution", + title="Address contact-point resolution", + summary="Resolve address contacts and lists into purpose-aware email and postal candidates.", + body=( + "When Addresses is enabled, Distribution Lists uses its versioned contact-point contract " + "to resolve communication purpose, requested channels, address purpose, fallback, locale, " + "quality, preference, consent, and suppression. Expansion previews explain rejected points. " + "Frozen Distribution List snapshots retain the exact rendered email or postal target, contact-" + "point ID, source revision and fingerprint, and decision provenance. If the new contract is " + "unavailable, older Addresses lookup and email-source capabilities remain a compatibility fallback." + ), + layer="available", + documentation_types=("admin", "user"), + audience=("operator", "module_admin", "product_owner"), + related_modules=("addresses", "campaigns", "policy"), + metadata={"seed": True}, + ), ) @@ -162,6 +184,7 @@ manifest = ModuleManifest( CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, CAPABILITY_ORGANIZATION_DIRECTORY, CAPABILITY_DATAFLOW_DATASET_OUTPUT, + CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, CAPABILITY_RECIPIENT_CHANNEL_FACTS, CAPABILITY_POLICY_DISTRIBUTION_CHANNELS, ), @@ -179,6 +202,20 @@ manifest = ModuleManifest( version=MODULE_VERSION, ), ), + requires_interfaces=( + ModuleInterfaceRequirement( + name=CAPABILITY_ADDRESSES_CONTACT_POINT_RESOLUTION, + version_min="1.0.0", + version_max_exclusive="2.0.0", + optional=True, + ), + ModuleInterfaceRequirement( + name=CAPABILITY_RECIPIENT_CHANNEL_FACTS, + version_min="0.1.0", + version_max_exclusive="0.2.0", + optional=True, + ), + ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, nav_items=( diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 7329200..0c78926 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -15,6 +15,17 @@ class DistributionListsManifestTests(unittest.TestCase): self.assertFalse(manifest.dependencies) self.assertIn("addresses", manifest.optional_dependencies) self.assertIn("auth.principalResolver", manifest.optional_capabilities) + self.assertIn( + "addresses.contact_point_resolution", + manifest.optional_capabilities, + ) + requirements = {item.name: item for item in manifest.requires_interfaces} + self.assertTrue(requirements["addresses.contact_point_resolution"].optional) + self.assertEqual( + "1.0.0", + requirements["addresses.contact_point_resolution"].version_min, + ) + self.assertTrue(requirements["addresses.channel_facts"].optional) self.assertIn("dist_lists.expand", {interface.name for interface in manifest.provides_interfaces}) self.assertIn("dist_lists:list:read", {permission.scope for permission in manifest.permissions}) self.assertIsNotNone(manifest.route_factory) diff --git a/tests/test_service.py b/tests/test_service.py index c99a2be..7aaa4ed 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -6,6 +6,12 @@ import unittest from govoplan_core.auth import ApiPrincipal from govoplan_core.core.access import PrincipalRef from govoplan_core.core.concurrency import RevisionConflictError +from govoplan_core.core.contact_points import ( + CONTACT_POINT_CONTRACT_VERSION, + ContactPointCandidate, + ContactPointResolution, + ContactPointSourcePreview, +) from govoplan_core.core.dataflows import ( DataflowDatasetDescriptor, DataflowDatasetResult, @@ -14,7 +20,9 @@ from govoplan_core.core.distribution_lists import ( DistributionChannelPolicyDecision, DistributionExpansionLimits, DistributionExpansionRequest, + DistributionExplanation, DistributionListConflictError, + DistributionSourceReference, ) from govoplan_core.db.base import Base from govoplan_core.db.session import configure_database, reset_database @@ -151,6 +159,120 @@ class _Policy: ) +class _ContactPoints: + def __init__(self) -> None: + self.resolve_request = None + self.source_requests = [] + source = DistributionSourceReference( + provider="addresses", + resource_type="contact", + resource_id="contact-1", + revision="contact-revision-1", + fingerprint="contact-fingerprint-1", + label="Ada Example", + ) + self.resolution = ContactPointResolution( + contract_version=CONTACT_POINT_CONTRACT_VERSION, + subject=source, + status="usable", + contact_id="contact-1", + display_name="Ada Example", + candidates=( + ContactPointCandidate( + channel="postal", + target="Ada Example\nMain Street 1\n10115 Berlin\nGermany", + target_key="postal:main street 1|10115|berlin||germany", + status="usable", + contact_point_id="postal-1", + address_purpose="official", + locale="de-DE", + preferred=True, + preference_rank=1, + source=source, + source_revision="facts-revision-1", + preference_revision="preference-revision-1", + consent_revision="consent-revision-1", + value={ + "street": "Main Street 1", + "postal_code": "10115", + "locality": "Berlin", + "country": "Germany", + }, + provenance={ + "rule_ids": ["rule-1"], + "source_revision": "contact-revision-1", + }, + ), + ), + excluded=( + ContactPointCandidate( + channel="email", + target="ada@example.test", + target_key="email:ada@example.test", + status="suppressed", + contact_point_id="email-1", + reason_code="addresses.channel.opted_out", + explanation="The contact opted out of email delivery.", + source=source, + source_revision="facts-revision-1", + provenance={ + "rule_ids": ["rule-2"], + "source_revision": "contact-revision-1", + }, + ), + ), + explanations=( + DistributionExplanation( + code="addresses.channel_fact.expired", + message="An older preference expired.", + severity="info", + provider="addresses", + source=source, + ), + ), + source_revision="facts-revision-1", + source_fingerprint="facts-fingerprint-1", + provenance={"active_rule_ids": ["rule-1", "rule-2"]}, + ) + + def resolve_contact_points(self, session, principal, *, request): + del session, principal + self.resolve_request = request + return self.resolution + + def preview_source(self, session, principal, *, request, offset=0, limit=100): + del session, principal + self.source_requests.append(request) + resolutions = (self.resolution, self.resolution)[offset : offset + limit] + return ContactPointSourcePreview( + contract_version=CONTACT_POINT_CONTRACT_VERSION, + source=DistributionSourceReference( + provider="addresses", + resource_type="address_list", + resource_id="list-1", + ), + request=request, + resolutions=resolutions, + total_count=2, + usable_count=len(resolutions), + excluded_count=len(resolutions), + offset=offset, + limit=limit, + has_more=offset + len(resolutions) < 2, + source_revision="list-revision-1", + source_fingerprint="list-fingerprint-1", + generated_at=datetime(2026, 1, 1, tzinfo=UTC), + provenance={"bounded": True}, + ) + + def freeze_source(self, session, principal, *, request): + raise AssertionError("Distribution Lists freezes its complete expansion snapshot.") + + def get_snapshot(self, session, principal, *, snapshot_id): + del session, principal, snapshot_id + return None + + class _Registry: def __init__(self, capabilities=None) -> None: self.capabilities = dict(capabilities or {}) @@ -494,6 +616,134 @@ class DistributionListServiceTests(unittest.TestCase): self.assertEqual("channel.not_requested", by_channel["email"].reason_code) self.assertEqual("usable", by_channel["postal"].status) + def test_address_contact_resolution_is_purpose_aware_and_frozen(self) -> None: + contact_points = _ContactPoints() + registry = _Registry( + {"addresses.contact_point_resolution": contact_points} + ) + with self.database.session() as session: + item, _ = create_distribution_list( + session, + principal(), + DistributionListCreateRequest.model_validate( + { + "name": "Official postal recipients", + "entries": [ + { + "entry_key": "ada", + "kind": "address_contact", + "purpose": "official_notice", + "requested_channels": ["postal"], + "source": { + "provider": "addresses", + "resource_type": "contact", + "resource_id": "contact-1", + "revision": "contact-revision-1", + }, + "configuration": { + "address_purpose": "official", + "fallback_rule": "none", + "locale": "de-DE", + "postal_format": "international", + }, + } + ], + } + ), + ) + result = expand_distribution_list( + session, + principal(), + registry=registry, + request=DistributionExpansionRequest( + list_id=item.id, + effective_at=datetime(2026, 1, 1, tzinfo=UTC), + requested_channels=("postal",), + freeze=True, + idempotency_key="official-postal-1", + ), + ) + session.commit() + + self.assertEqual(1, len(result.recipients)) + recipient = result.recipients[0] + self.assertEqual("contact-1", recipient.contact_id) + self.assertEqual("postal-1", recipient.channels[0].contact_point_id) + self.assertIn("Main Street 1", recipient.channels[0].target) + self.assertEqual("suppressed", recipient.channels[1].status) + self.assertEqual( + "addresses.channel.opted_out", + recipient.channels[1].reason_code, + ) + self.assertEqual("official_notice", contact_points.resolve_request.purpose) + self.assertEqual("official", contact_points.resolve_request.address_purpose) + self.assertEqual("international", contact_points.resolve_request.postal_format) + self.assertEqual( + "1.0", + recipient.provenance["contact_point_resolution"]["contract_version"], + ) + snapshot = session.get(DistributionListSnapshot, result.snapshot_id) + self.assertIn("Main Street 1", snapshot.recipients[0]["channels"][0]["target"]) + self.assertEqual( + "postal-1", + snapshot.recipients[0]["channels"][0]["contact_point_id"], + ) + self.assertEqual( + "facts-fingerprint-1", + snapshot.provider_evidence[0]["actual_fingerprint"], + ) + + def test_address_list_resolution_is_bounded_and_channel_neutral(self) -> None: + contact_points = _ContactPoints() + registry = _Registry( + {"addresses.contact_point_resolution": contact_points} + ) + with self.database.session() as session: + item, _ = create_distribution_list( + session, + principal(), + DistributionListCreateRequest.model_validate( + { + "name": "Address source", + "entries": [ + { + "entry_key": "address-list", + "kind": "address_list", + "source": { + "provider": "addresses", + "resource_type": "address_list", + "resource_id": "addresses:address_list:list-1", + }, + } + ], + } + ), + ) + result = expand_distribution_list( + session, + principal(), + registry=registry, + request=DistributionExpansionRequest( + list_id=item.id, + effective_at=datetime(2026, 1, 1, tzinfo=UTC), + requested_channels=("postal",), + limits=DistributionExpansionLimits(max_provider_results=1), + ), + ) + + self.assertEqual(1, len(result.recipients)) + self.assertTrue(result.truncated) + self.assertEqual( + "addresses:address_list:list-1", + contact_points.source_requests[0].source_id, + ) + self.assertEqual(("postal",), contact_points.source_requests[0].requested_channels) + self.assertEqual("list-revision-1", result.provider_evidence[0].actual_revision) + self.assertIn( + "provider.result_limit", + {item.code for item in result.diagnostics}, + ) + def _nested_entry(list_id: str, key: str) -> dict[str, object]: return { diff --git a/webui/src/features/distributionLists/DistributionListsPage.tsx b/webui/src/features/distributionLists/DistributionListsPage.tsx index fbab567..6d7bc4a 100644 --- a/webui/src/features/distributionLists/DistributionListsPage.tsx +++ b/webui/src/features/distributionLists/DistributionListsPage.tsx @@ -930,6 +930,55 @@ function EntryEditorDialog({ onChange({ ...state, effectiveUntil: event.target.value })} /> + {isAddressProviderEntry(state) ? ( + <> + + onChange({ + ...state, + configuration: { ...state.configuration, address_purpose: event.target.value } + })} + placeholder="Any purpose" + /> + + + + + + onChange({ + ...state, + configuration: { ...state.configuration, locale: event.target.value } + })} + placeholder="For example de-DE" + /> + + + + + + ) : null}
Requested channels @@ -1155,6 +1204,11 @@ function directKind(mode: DirectSourceMode): EntryKind { return mode; } +function isAddressProviderEntry(state: EntryEditorState): boolean { + return state.sourceMode === "provider" + && Boolean(state.providerOption?.kind.startsWith("address_")); +} + function directModeForKind(kind: EntryKind): SourceMode { if (kind === "raw_email") return "email"; if (kind === "raw_postal_address") return "postal";