diff --git a/docs/MAIL_HANDBOOK.md b/docs/MAIL_HANDBOOK.md index dcbdcde..23ce01b 100644 --- a/docs/MAIL_HANDBOOK.md +++ b/docs/MAIL_HANDBOOK.md @@ -61,6 +61,14 @@ conflicting profile is preserved unless the reviewed fragment explicitly sets `on_conflict` to `update`. Credential bindings are added idempotently and are never removed merely because a package omits a credential reference. +Mail also registers a module-owned infrastructure dependency provider. Its +authorized Ops inventory lists every persisted SMTP endpoint and legacy SMTP +profile by stable non-secret reference, state and scope, together with numeric +credential-binding evidence. Before the host deployer changes or removes +`mail.smtp`, it requires a fresh, complete inventory from the same installation +and displays these dependencies in the plan. The inventory never contains +transport credentials or decrypted envelope data. + Preflight does not prove SMTP reachability. After apply, use the normal Mail profile test and Ops health surfaces. If the receipt says SMTP is unavailable, is invalid, or is not mounted, import is blocked with an operator-facing diff --git a/package-lock.json b/package-lock.json index 175fda7..f73d501 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/mail-webui", - "version": "0.1.25", + "version": "0.1.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/mail-webui", - "version": "0.1.25", + "version": "0.1.26", "peerDependencies": { "@govoplan/core-webui": "^0.1.18", "lucide-react": "^1.23.0", diff --git a/package.json b/package.json index 4281776..c954906 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/mail-webui", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "main": "webui/src/index.ts", diff --git a/pyproject.toml b/pyproject.toml index 55d1326..a481bbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-mail" -version = "0.1.25" +version = "0.1.26" description = "GovOPlaN mail module with backend and WebUI integration." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.18", + "govoplan-core>=0.1.42", "pydantic>=2,<3", "redis>=5,<6", "SQLAlchemy>=2,<3", diff --git a/src/govoplan_mail/backend/configuration_provider.py b/src/govoplan_mail/backend/configuration_provider.py index 404b8ce..3ffccb7 100644 --- a/src/govoplan_mail/backend/configuration_provider.py +++ b/src/govoplan_mail/backend/configuration_provider.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, replace import re from typing import Any -from sqlalchemy import or_, select +from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from govoplan_core.core.configuration_packages import ( @@ -24,6 +24,8 @@ from govoplan_core.core.configuration_packages import ( from govoplan_core.core.infrastructure_capabilities import ( InfrastructureCapability, InfrastructureCapabilityReceipt, + InfrastructureDependency, + InfrastructureDependencyProvider, ) from govoplan_core.security.credential_envelopes import ( CredentialAccessContext, @@ -51,6 +53,9 @@ from govoplan_mail.backend.server_hierarchy import ( MAIL_CONFIGURATION_CAPABILITY = "mail.configuration" +MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY = ( + "infrastructure.dependency_inventory.mail" +) SMTP_PROFILE_FRAGMENT = "smtp_profile" _SYSTEM_CONFIGURATION_SCOPES = frozenset( {"system:settings:write", "system:governance:write"} @@ -132,8 +137,12 @@ class _ProfileTarget: on_conflict: str -class SqlMailConfigurationProvider(ConfigurationProvider): +class SqlMailConfigurationProvider( + ConfigurationProvider, + InfrastructureDependencyProvider, +): module_id = "mail" + capability_ids = ("mail.smtp",) def describe(self) -> ConfigurationProviderDescription: return ConfigurationProviderDescription( @@ -204,6 +213,84 @@ class SqlMailConfigurationProvider(ConfigurationProvider): item for item in import_result.diagnostics if item.severity == "blocker" ) + def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]: + with get_database().session() as session: + return _smtp_infrastructure_dependencies(session) + + +def _smtp_infrastructure_dependencies( + session: Session, +) -> tuple[InfrastructureDependency, ...]: + endpoints = tuple( + session.execute( + select(MailServerEndpoint) + .where(MailServerEndpoint.protocol == "smtp") + .order_by(MailServerEndpoint.id) + ).scalars() + ) + binding_counts = { + str(server_id): int(count) + for server_id, count in session.execute( + select( + MailServerCredentialBinding.server_id, + func.count(MailServerCredentialBinding.id), + ).group_by(MailServerCredentialBinding.server_id) + ) + } + dependencies: list[InfrastructureDependency] = [] + endpoint_profile_ids: set[str] = set() + for endpoint in endpoints: + endpoint_profile_ids.add(endpoint.profile_id) + dependencies.append( + InfrastructureDependency( + capability_id="mail.smtp", + module_id="mail", + dependency_type="smtp_endpoint", + dependency_ref=mail_server_ref(endpoint.id) or f"mail:{endpoint.id}", + state="active" if endpoint.is_active else "inactive", + scope=str(endpoint.scope_type or "tenant"), + summary=( + "A persisted Mail SMTP endpoint is bound to the deployment relay." + ), + metrics={ + "credential_binding_count": binding_counts.get(endpoint.id, 0), + "default_endpoint": int(bool(endpoint.is_default)), + }, + required_action=( + "Rebind, migrate, or explicitly retire this SMTP endpoint and its credential-envelope references before changing the relay capability." + ), + ) + ) + + legacy_profiles = tuple( + session.execute( + select(MailServerProfile) + .where(MailServerProfile.smtp_config.is_not(None)) + .order_by(MailServerProfile.id) + ).scalars() + ) + for profile in legacy_profiles: + if profile.id in endpoint_profile_ids or not dict(profile.smtp_config or {}): + continue + dependencies.append( + InfrastructureDependency( + capability_id="mail.smtp", + module_id="mail", + dependency_type="legacy_smtp_profile", + dependency_ref=f"mail-profile:{profile.id}", + state="active" if profile.is_active else "inactive", + scope=str(profile.scope_type or "tenant"), + summary=( + "A persisted legacy Mail profile still contains SMTP transport configuration." + ), + metrics={"credential_binding_count": 0}, + required_action=( + "Migrate or explicitly retire this legacy profile before changing the relay capability." + ), + ) + ) + return tuple(dependencies) + def _preflight_smtp_profile( session: Session, diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index e1cc56f..b690aff 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -48,7 +48,10 @@ from govoplan_core.core.provider_governance import ( from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base -from govoplan_mail.backend.configuration_provider import MAIL_CONFIGURATION_CAPABILITY +from govoplan_mail.backend.configuration_provider import ( + MAIL_CONFIGURATION_CAPABILITY, + MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY, +) from govoplan_mail.backend.documentation import ( documentation_configuration_states, documentation_topics, @@ -447,7 +450,7 @@ POP3_PROVIDER = ExternalProviderDeclaration( manifest = ModuleManifest( id="mail", name="Mail", - version="0.1.25", + version="0.1.26", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"), provides_interfaces=( @@ -642,6 +645,7 @@ manifest = ModuleManifest( ), capability_factories={ MAIL_CONFIGURATION_CAPABILITY: _configuration_provider, + MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY: _configuration_provider, "mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__( "govoplan_mail.backend.capabilities", @@ -878,7 +882,9 @@ manifest = ModuleManifest( "The Mail configuration provider reads the validated mail.smtp deployment capability and derives authoritative endpoint metadata. " "Missing non-secret transport fields are collected by preflight; authentication is represented only by an existing credential-envelope id. " "Tenant scope is the default, system scope requires system authority, conflicting profiles are preserved unless update is explicitly reviewed, " - "and an unchanged second apply is a no-op. SMTP reachability remains a separate Mail profile test." + "and an unchanged second apply is a no-op. Before the host deployer changes or removes mail.smtp, Mail inventories every persisted SMTP endpoint, " + "legacy SMTP profile, and credential-binding count through the non-secret Ops dependency report. Missing, stale, or incomplete inventory blocks apply; " + "SMTP reachability remains a separate Mail profile test." ), layer="configured", documentation_types=("admin",), @@ -910,7 +916,9 @@ manifest = ModuleManifest( "Der Mail-Konfigurationsprovider liest die validierte Bereitstellungsfähigkeit mail.smtp und übernimmt deren maßgebliche Endpunktdaten. " "Fehlende nicht geheime Transportangaben werden im Preflight abgefragt; Authentifizierung wird ausschließlich durch die ID eines vorhandenen Zugangsdaten-Umschlags referenziert. " "Mandantenbezug ist der Standard, Systembezug erfordert Systemberechtigung, abweichende vorhandene Profile bleiben ohne ausdrücklich geprüfte Aktualisierung unverändert, " - "und eine unveränderte zweite Anwendung bleibt wirkungslos. Die SMTP-Erreichbarkeit wird weiterhin separat im Mail-Profil getestet." + "und eine unveränderte zweite Anwendung bleibt wirkungslos. Bevor der Host-Deployer mail.smtp ändert oder entfernt, inventarisiert Mail alle gespeicherten SMTP-Endpunkte, " + "älteren SMTP-Profile und die Anzahl ihrer Zugangsdatenbindungen im nicht geheimen Ops-Abhängigkeitsbericht. Ein fehlendes, veraltetes oder unvollständiges Inventar blockiert die Anwendung; " + "die SMTP-Erreichbarkeit wird weiterhin separat im Mail-Profil getestet." ), } }, diff --git a/tests/test_configuration_provider.py b/tests/test_configuration_provider.py index b5df4e9..008c2d8 100644 --- a/tests/test_configuration_provider.py +++ b/tests/test_configuration_provider.py @@ -22,6 +22,7 @@ from govoplan_core.db.session import configure_database, reset_database from govoplan_core.security.credential_envelopes import CredentialEnvelope from govoplan_mail.backend.configuration_provider import ( MAIL_CONFIGURATION_CAPABILITY, + MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY, SqlMailConfigurationProvider, ) from govoplan_mail.backend.db.models import ( @@ -168,9 +169,30 @@ class MailConfigurationProviderTests(unittest.TestCase): def test_provider_is_registered_and_describes_receipt_bound_fragment(self) -> None: self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories) + self.assertIn( + MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY, + manifest.capability_factories, + ) description = self.provider.describe() self.assertEqual(("smtp_profile",), description.fragment_types) + def test_inventory_reports_actual_smtp_endpoint_and_credential_binding(self) -> None: + fragment = ConfigurationPackageFragment( + module_id="mail", + fragment_type="smtp_profile", + fragment_id="inventory-smtp", + payload={"credential_envelope_id": "credential-1"}, + ) + self.provider.apply(fragment, {}, self.context) + + dependencies = self.provider.infrastructure_dependencies() + + self.assertEqual(1, len(dependencies)) + self.assertEqual("mail.smtp", dependencies[0].capability_id) + self.assertEqual("smtp_endpoint", dependencies[0].dependency_type) + self.assertEqual(1, dependencies[0].metrics["credential_binding_count"]) + self.assertNotIn("test-mail", str(dependencies[0].to_dict())) + def test_apply_is_idempotent_and_binds_existing_credential_envelope(self) -> None: fragment = ConfigurationPackageFragment( module_id="mail", diff --git a/webui/package-lock.json b/webui/package-lock.json index 0ceff79..8232558 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/mail-webui", - "version": "0.1.25", + "version": "0.1.26", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/mail-webui", - "version": "0.1.25", + "version": "0.1.26", "devDependencies": { "typescript": "^5.7.2" }, diff --git a/webui/package.json b/webui/package.json index 6ec2a30..918b8c0 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/mail-webui", - "version": "0.1.25", + "version": "0.1.26", "private": true, "type": "module", "main": "src/index.ts",