Compare commits

...
2 Commits
Author SHA1 Message Date
zemion c62c7783d6 feat: inventory SMTP infrastructure dependencies
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 14:56:27 +02:00
zemion ecc1283de7 feat: contribute Messages product surface
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 12:53:44 +02:00
10 changed files with 183 additions and 17 deletions
+8
View File
@@ -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 `on_conflict` to `update`. Credential bindings are added idempotently and are
never removed merely because a package omits a credential reference. 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 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, 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 is invalid, or is not mounted, import is blocked with an operator-facing
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.24", "version": "0.1.26",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.24", "version": "0.1.26",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.24", "version": "0.1.26",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-mail" name = "govoplan-mail"
version = "0.1.24" version = "0.1.26"
description = "GovOPlaN mail module with backend and WebUI integration." description = "GovOPlaN mail module with backend and WebUI integration."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = { file = "LICENSE" } license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.18", "govoplan-core>=0.1.42",
"pydantic>=2,<3", "pydantic>=2,<3",
"redis>=5,<6", "redis>=5,<6",
"SQLAlchemy>=2,<3", "SQLAlchemy>=2,<3",
@@ -5,7 +5,7 @@ from dataclasses import dataclass, replace
import re import re
from typing import Any from typing import Any
from sqlalchemy import or_, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.configuration_packages import ( 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 ( from govoplan_core.core.infrastructure_capabilities import (
InfrastructureCapability, InfrastructureCapability,
InfrastructureCapabilityReceipt, InfrastructureCapabilityReceipt,
InfrastructureDependency,
InfrastructureDependencyProvider,
) )
from govoplan_core.security.credential_envelopes import ( from govoplan_core.security.credential_envelopes import (
CredentialAccessContext, CredentialAccessContext,
@@ -51,6 +53,9 @@ from govoplan_mail.backend.server_hierarchy import (
MAIL_CONFIGURATION_CAPABILITY = "mail.configuration" MAIL_CONFIGURATION_CAPABILITY = "mail.configuration"
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY = (
"infrastructure.dependency_inventory.mail"
)
SMTP_PROFILE_FRAGMENT = "smtp_profile" SMTP_PROFILE_FRAGMENT = "smtp_profile"
_SYSTEM_CONFIGURATION_SCOPES = frozenset( _SYSTEM_CONFIGURATION_SCOPES = frozenset(
{"system:settings:write", "system:governance:write"} {"system:settings:write", "system:governance:write"}
@@ -132,8 +137,12 @@ class _ProfileTarget:
on_conflict: str on_conflict: str
class SqlMailConfigurationProvider(ConfigurationProvider): class SqlMailConfigurationProvider(
ConfigurationProvider,
InfrastructureDependencyProvider,
):
module_id = "mail" module_id = "mail"
capability_ids = ("mail.smtp",)
def describe(self) -> ConfigurationProviderDescription: def describe(self) -> ConfigurationProviderDescription:
return ConfigurationProviderDescription( return ConfigurationProviderDescription(
@@ -204,6 +213,84 @@ class SqlMailConfigurationProvider(ConfigurationProvider):
item for item in import_result.diagnostics if item.severity == "blocker" 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( def _preflight_smtp_profile(
session: Session, session: Session,
+53 -4
View File
@@ -32,7 +32,9 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAvailabilityExplanation,
ProductAreaContribution, ProductAreaContribution,
ProductSurfaceContribution,
QuickAccessTool, QuickAccessTool,
RoleTemplate, RoleTemplate,
) )
@@ -46,7 +48,10 @@ from govoplan_core.core.provider_governance import (
from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base 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 ( from govoplan_mail.backend.documentation import (
documentation_configuration_states, documentation_configuration_states,
documentation_topics, documentation_topics,
@@ -445,7 +450,7 @@ POP3_PROVIDER = ExternalProviderDeclaration(
manifest = ModuleManifest( manifest = ModuleManifest(
id="mail", id="mail",
name="Mail", name="Mail",
version="0.1.24", version="0.1.26",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"), optional_dependencies=("campaigns", "addresses", "calendar", "postbox", "search"),
provides_interfaces=( provides_interfaces=(
@@ -562,6 +567,39 @@ manifest = ModuleManifest(
order=40, order=40,
), ),
), ),
product_surfaces=(
ProductSurfaceContribution(
id="communication.messages",
module_id="mail",
label="i18n:govoplan-core.product_surface.messages",
description="i18n:govoplan-core.product_surface.messages_description",
icon="mail",
entry_path="/messages",
route_path="/mail",
surface_ids=("mail.nav.mail", "mail.route.mail"),
presentations=("task", "reader"),
search_source_ids=("mail.mailbox_messages",),
help_context_ids=("mail.quick_access.messages",),
documentation_topic_ids=("mail.quick-access-and-product-area",),
required_any=("mail:mailbox:read",),
aliases=("/inbox",),
order=10,
unavailable=ProductAvailabilityExplanation(
reason="authorization",
title="i18n:govoplan-core.product_surface.messages_unavailable",
description="i18n:govoplan-core.product_surface.messages_unavailable_description",
resolution="i18n:govoplan-core.product_surface.messages_unavailable_resolution",
responsible_role="i18n:govoplan-core.access_administrator",
),
degraded=ProductAvailabilityExplanation(
reason="provider_degraded",
title="i18n:govoplan-core.product_surface.messages_degraded",
description="i18n:govoplan-core.product_surface.messages_degraded_description",
resolution="i18n:govoplan-core.product_surface.messages_degraded_resolution",
responsible_role="i18n:govoplan-core.integration_operator",
),
),
),
quick_access_tools=( quick_access_tools=(
QuickAccessTool( QuickAccessTool(
id="mail.messages", id="mail.messages",
@@ -607,6 +645,7 @@ manifest = ModuleManifest(
), ),
capability_factories={ capability_factories={
MAIL_CONFIGURATION_CAPABILITY: _configuration_provider, 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), "mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__( CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__(
"govoplan_mail.backend.capabilities", "govoplan_mail.backend.capabilities",
@@ -843,7 +882,9 @@ manifest = ModuleManifest(
"The Mail configuration provider reads the validated mail.smtp deployment capability and derives authoritative endpoint metadata. " "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. " "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, " "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", layer="configured",
documentation_types=("admin",), documentation_types=("admin",),
@@ -875,7 +916,9 @@ manifest = ModuleManifest(
"Der Mail-Konfigurationsprovider liest die validierte Bereitstellungsfähigkeit mail.smtp und übernimmt deren maßgebliche Endpunktdaten. " "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. " "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, " "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."
), ),
} }
}, },
@@ -893,6 +936,9 @@ manifest = ModuleManifest(
body=( body=(
"Mail contributes its full mailbox to Communication. When Quick Access is enabled, its compact provider surface " "Mail contributes its full mailbox to Communication. When Quick Access is enabled, its compact provider surface "
"appears inside the shared Messages drawer alongside independent Postbox and future chat contributions. " "appears inside the shared Messages drawer alongside independent Postbox and future chat contributions. "
"Mail also contributes to the stable Messages product entry: `/messages` and its migration alias `/inbox` open "
"Mail only when its mailbox route is currently authorized and visible in the effective View; otherwise another "
"available Messages owner is selected or the shell explains how access can be restored. "
"Recent entries open the exact authorized profile, folder, and message; Drafts resolves the configured or " "Recent entries open the exact authorized profile, folder, and message; Drafts resolves the configured or "
"provider-detected Drafts folder, and the full Mail page keeps the versioned Quick Access return context. " "provider-detected Drafts folder, and the full Mail page keeps the versioned Quick Access return context. "
"When an active Case requests a selection, choosing a recent message returns only its tenant-bound profile, " "When an active Case requests a selection, choosing a recent message returns only its tenant-bound profile, "
@@ -914,6 +960,9 @@ manifest = ModuleManifest(
"body": ( "body": (
"Mail ordnet das vollständige Postfach Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die kompakte " "Mail ordnet das vollständige Postfach Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die kompakte "
"Mail-Oberfläche gemeinsam mit unabhängigen Beiträgen aus Postbox und künftig Chat unter Nachrichten. " "Mail-Oberfläche gemeinsam mit unabhängigen Beiträgen aus Postbox und künftig Chat unter Nachrichten. "
"Mail trägt außerdem zum stabilen Produkteinstieg Nachrichten bei: `/messages` und der Migrationsalias `/inbox` "
"öffnen Mail nur, wenn die Postfachroute aktuell berechtigt und in der wirksamen View sichtbar ist. Andernfalls "
"wählt die Shell einen anderen verfügbaren Nachrichten-Eigentümer oder erklärt die Wiederherstellung des Zugriffs. "
"Aktuelle Einträge öffnen das genaue berechtigte Profil, den Ordner und die Nachricht; Entwürfe verwendet " "Aktuelle Einträge öffnen das genaue berechtigte Profil, den Ordner und die Nachricht; Entwürfe verwendet "
"den konfigurierten oder vom Anbieter erkannten Entwurfsordner. Die vollständige Mail-Seite erhält den " "den konfigurierten oder vom Anbieter erkannten Entwurfsordner. Die vollständige Mail-Seite erhält den "
"versionierten Rücksprungkontext. Verfassen öffnet bewusst die konfigurierte Mail-Anwendung des Benutzers, " "versionierten Rücksprungkontext. Verfassen öffnet bewusst die konfigurierte Mail-Anwendung des Benutzers, "
+22
View File
@@ -22,6 +22,7 @@ from govoplan_core.db.session import configure_database, reset_database
from govoplan_core.security.credential_envelopes import CredentialEnvelope from govoplan_core.security.credential_envelopes import CredentialEnvelope
from govoplan_mail.backend.configuration_provider import ( from govoplan_mail.backend.configuration_provider import (
MAIL_CONFIGURATION_CAPABILITY, MAIL_CONFIGURATION_CAPABILITY,
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
SqlMailConfigurationProvider, SqlMailConfigurationProvider,
) )
from govoplan_mail.backend.db.models import ( 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: def test_provider_is_registered_and_describes_receipt_bound_fragment(self) -> None:
self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories) self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories)
self.assertIn(
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
manifest.capability_factories,
)
description = self.provider.describe() description = self.provider.describe()
self.assertEqual(("smtp_profile",), description.fragment_types) 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: def test_apply_is_idempotent_and_binds_existing_credential_envelope(self) -> None:
fragment = ConfigurationPackageFragment( fragment = ConfigurationPackageFragment(
module_id="mail", module_id="mail",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.24", "version": "0.1.26",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.24", "version": "0.1.26",
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.24", "version": "0.1.26",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+3 -3
View File
@@ -1,5 +1,5 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { MailDevMailboxUiCapability, MailProfilesUiCapability, PlatformWebModule, QuickAccessToolsUiCapability } from "@govoplan/core-webui"; import { messagesProductSurfaceTranslations, type MailDevMailboxUiCapability, type MailProfilesUiCapability, type PlatformWebModule, type QuickAccessToolsUiCapability } from "@govoplan/core-webui";
import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement"; import { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
import { validateMailPolicy } from "./features/mail/mailPolicyValidation"; import { validateMailPolicy } from "./features/mail/mailPolicyValidation";
import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders"; import { mailCredentialReferenceSelectors } from "./features/mail/mailReferenceProviders";
@@ -14,8 +14,8 @@ const mailboxRead = ["mail:mailbox:read"];
const bounceRead = ["mail:bounce:read", "mail:bounce:manage"]; const bounceRead = ["mail:bounce:read", "mail:bounce:manage"];
const legacyImportAccess = ["mail:pop3:import", "mail:pop3:manage"]; const legacyImportAccess = ["mail:pop3:import", "mail:pop3:manage"];
const translations = { const translations = {
en: generatedTranslations.en, en: { ...generatedTranslations.en, ...messagesProductSurfaceTranslations.en },
de: generatedTranslations.de de: { ...generatedTranslations.de, ...messagesProductSurfaceTranslations.de }
}; };
const mailQuickAccessTools: QuickAccessToolsUiCapability = { const mailQuickAccessTools: QuickAccessToolsUiCapability = {
tools: [ tools: [