Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c62c7783d6 |
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/mail-webui",
|
||||
"version": "0.1.25",
|
||||
"version": "0.1.26",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
Reference in New Issue
Block a user