Files
govoplan-mail/src/govoplan_mail/backend/manifest.py

419 lines
22 KiB
Python

from __future__ import annotations
from dataclasses import replace
from pathlib import Path
from sqlalchemy import inspect
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.db.base import Base
from govoplan_mail.backend.documentation import documentation_topics
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
_mail_table_retirement_provider = drop_table_retirement_provider(
mail_models.MailServerProfile,
mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex,
mail_models.MailMailboxMessageIndex,
label="Mail",
)
def _mail_retirement_provider(session: object | None, module_id: str):
plan = _mail_table_retirement_provider(session, module_id)
base_executor = plan.destroy_data_executor
if base_executor is None:
return plan
def executor(execute_session: object, execute_module_id: str) -> None:
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
raise RuntimeError("No database session is available for Mail credential retirement.")
if inspect(execute_session.get_bind()).has_table(mail_models.MailServerProfile.__tablename__):
from govoplan_mail.backend.mail_profiles import delete_mail_profile_credentials_for_retirement
delete_mail_profile_credentials_for_retirement(execute_session)
base_executor(execute_session, execute_module_id)
return replace(
plan,
destroy_data_warnings=(
*plan.destroy_data_warnings,
"Mail-owned encrypted SMTP/IMAP passwords are scrubbed with non-secret audit records immediately before tables are dropped; any scrub or audit failure blocks retirement.",
),
destroy_data_executor=executor,
)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Mail",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
PERMISSIONS = (
_permission("mail:profile:read", "View mail profiles", "Inspect reusable SMTP/IMAP profile metadata."),
_permission("mail:profile:use", "Use mail profiles", "Select an approved mail profile for delivery."),
_permission("mail:profile:test", "Test mail profiles", "Run SMTP/IMAP connection tests."),
_permission("mail:mailbox:read", "Read mailboxes", "List IMAP folders and inspect messages without mutating mailbox state."),
_permission("mail:profile:write", "Manage mail profiles", "Create and edit reusable mail profiles."),
_permission("mail:secret:manage", "Manage mail secrets", "Create or replace stored SMTP/IMAP credentials."),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="mail_profile_admin",
name="Mail profile administrator",
description="Manage reusable mail profiles and credentials.",
permissions=(
"mail:profile:read",
"mail:profile:use",
"mail:profile:test",
"mail:mailbox:read",
"mail:profile:write",
"mail:secret:manage",
),
),
RoleTemplate(
slug="mail_profile_user",
name="Mail profile user",
description="Use and test approved mail profiles without reading secrets.",
permissions=("mail:profile:read", "mail:profile:use", "mail:profile:test", "mail:mailbox:read"),
),
)
def _mail_router(context: ModuleContext):
from govoplan_mail.backend.runtime import configure_runtime
configure_runtime(registry=context.registry, settings=context.settings)
from fastapi import APIRouter
from govoplan_mail.backend.router import router
aggregate = APIRouter()
aggregate.include_router(router)
app_env = str(getattr(context.settings, "app_env", "dev")).lower()
if app_env == "dev" and bool(getattr(context.settings, "dev_mailbox_api_enabled", False)):
from govoplan_mail.backend.dev_router import router as dev_router
aggregate.include_router(dev_router)
return aggregate
manifest = ModuleManifest(
id="mail",
name="Mail",
version="0.1.9",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "addresses"),
provides_interfaces=(
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="campaigns.access",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="campaigns.mail_policy_context",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="addresses.lookup",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS,
route_factory=_mail_router,
role_templates=ROLE_TEMPLATES,
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), order=50),),
frontend=FrontendModule(
module_id="mail",
package_name="@govoplan/mail-webui",
nav_items=(NavItem(path="/mail", label="Mail", icon="mail", required_any=("mail:mailbox:read",), order=50),),
),
migration_spec=MigrationSpec(
module_id="mail",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=_mail_retirement_provider,
retirement_notes="Destructive retirement first scrubs and audits Mail-owned credentials, then drops Mail-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
mail_models.MailServerProfile,
mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex,
mail_models.MailMailboxMessageIndex,
label="Mail",
),
),
capability_factories={
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
},
documentation=(
DocumentationTopic(
id="mail.profiles-and-policy",
title="Mail profiles and policy hierarchy",
summary="Mail sending is configured through reusable SMTP/IMAP profiles and an effective policy assembled from system, tenant, owner, and campaign scope where applicable.",
body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define additional reusable profiles. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.",
layer="configured",
documentation_types=("admin",),
audience=("tenant_admin", "mail_admin", "campaign_admin"),
order=39,
i18n_key="mail.topic.profiles_and_policy",
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=("mail:profile:read", "admin:policies:read", "system:settings:read"),
configuration_keys=("mail_profile_policy",),
),
),
links=(
DocumentationLink(label="Mail profiles", href="/api/v1/mail/profiles", kind="api"),
DocumentationLink(label="Tenant mail policy", href="/api/v1/mail/policies/tenant", kind="api"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Public mail module documentation", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
),
related_modules=("campaigns",),
unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",),
configuration_keys=("mail_profile_policy",),
metadata={
"kind": "reference",
"route": "/settings?section=mail-profiles",
"screen": "Mail profiles and policy",
"section": "Effective profile policy",
"related_topic_ids": [
"mail.profile-ownership-and-consumers",
"campaigns.mail-profile-governance",
],
},
),
DocumentationTopic(
id="mail.profile-ownership-and-consumers",
title="Mail owns transport profiles and credentials",
summary="Other modules select authorized Mail profiles by stable identifier; they do not copy SMTP/IMAP settings or secrets.",
body="Mail encrypts credentials, tests connections, evaluates profile scope and policy, and performs revision-gated transport effects without returning resolved configuration. Random persisted revisions rotate when normalized transport or account identity changes, while password-only rotation remains transparent to built business intent. Campaign stores only server.mail_profile_id plus sanitized delivery evidence. Campaign-local transport fields are rejected, and legacy campaign records fail closed until an explicit profile migration preserves the source audit record and updates an editable version.",
layer="available",
documentation_types=("admin", "user"),
audience=("mail_user", "mail_admin", "campaign_manager", "campaign_sender"),
order=40,
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=("mail:profile:read", "mail:profile:use", "mail:profile:write"),
),
),
links=(
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
DocumentationLink(label="Mail profiles API", href="/api/v1/mail/profiles", kind="api"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
),
related_modules=("campaigns", "access"),
unlocks=("Reusable, governed delivery identities without cross-module secret duplication.",),
metadata={
"kind": "reference",
"route": "/settings?section=mail-profiles",
"screen": "Mail profiles",
"section": "Profile ownership, credentials, and consumers",
"related_topic_ids": [
"mail.profiles-and-policy",
"campaigns.mail-profile-user-journey",
"campaigns.mail-profile-governance",
"campaigns.mail-profile-operations",
],
},
),
DocumentationTopic(
id="mail.workflow.choose-and-test-profile",
title="Choose and test a reusable Mail profile",
summary="Select a profile visible in the current context, test its SMTP or IMAP connection, and let the consuming task store only its stable reference.",
body="Profile tests verify current connection and authentication using Mail-owned credentials. They do not prove later Campaign policy authorization, deliverability, recipient acceptance, or future availability. Use the picker rather than entering identifiers or copying transport settings into another module.",
layer="configured",
documentation_types=("user",),
audience=("mail_user", "campaign_manager"),
order=41,
conditions=(
DocumentationCondition(
required_modules=("mail",),
required_scopes=("mail:profile:read", "mail:profile:use", "mail:profile:test"),
),
),
links=(
DocumentationLink(label="Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
),
related_modules=("campaigns",),
unlocks=("Reusable SMTP/IMAP identities that consumers can reference without handling credentials.",),
metadata={
"kind": "workflow",
"route": "/settings?section=mail-profiles",
"screen": "Mail profiles",
"help_contexts": ["mail.profiles", "app.settings"],
"prerequisites": [
"Mail is installed and you may read, use, and test profiles visible in the current context.",
"A profile administrator has configured credentials and effective policy.",
],
"steps": [
"Open Mail profiles and choose a visible active profile.",
"Review its safe scope, SMTP/IMAP availability, and sender identity without expecting credential values.",
"Run the relevant SMTP or IMAP connectivity/authentication test against a non-production target first.",
"Return to the consuming task and select the same profile through its picker.",
],
"outcome": "The consuming task references an available Mail-owned profile and contains no copied transport configuration or credentials.",
"verification": "Reload both surfaces, confirm only the stable reference is retained by the consumer, and perform the consumer's own contextual policy validation.",
"related_topic_ids": [
"mail.profile-ownership-and-consumers",
"mail.reference.campaign-delivery-contract",
],
},
),
DocumentationTopic(
id="mail.workflow.read-mailbox",
title="Read a permitted mailbox without changing it",
summary="Choose an IMAP-enabled profile, browse folders, and inspect bounded message content through the read-only mailbox surface.",
body="Mailbox access requires both mailbox-read and profile-use authority for a profile visible in the actor's scope. Listing folders or messages must not mark mail read, move it, delete it, or expose unbounded content.",
layer="configured",
documentation_types=("user",),
audience=("mail_user",),
order=42,
conditions=(
DocumentationCondition(
required_modules=("mail",),
required_scopes=("mail:mailbox:read", "mail:profile:use"),
),
),
links=(
DocumentationLink(label="Mail", href="/mail", kind="runtime"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
),
unlocks=("Read-only access to an authorized operational mailbox without broadening profile administration rights.",),
metadata={
"kind": "workflow",
"route": "/mail",
"screen": "Mail",
"help_contexts": ["mail.list"],
"prerequisites": [
"An active visible profile has IMAP configured.",
"You may both use that profile and read its mailbox.",
],
"steps": [
"Open Mail and choose an authorized IMAP-enabled profile.",
"Select a folder and page through its bounded message index.",
"Open only the message needed for the task and close it when finished.",
],
"outcome": "The required message was inspected without changing provider mailbox state.",
"verification": "Refresh the provider mailbox independently and confirm no read, move, delete, reply, or flag mutation was caused by GovOPlaN.",
"related_topic_ids": [
"mail.workflow.choose-and-test-profile",
"mail.reference.credentials-egress-retirement",
],
},
),
DocumentationTopic(
id="mail.reference.credentials-egress-retirement",
title="Protect Mail credentials, network egress, and retirement",
summary="Keep secrets Mail-owned, pin every SMTP/IMAP peer, bound responses, and delete owned credentials immediately with non-secret audit evidence.",
body="Private-network connector access is deployment-wide, but every allowed hostname still resolves to an approved peer that is pinned at socket creation. Unsupported transports fail before connection. Deleting a profile immediately scrubs its owned encrypted SMTP/IMAP passwords and records non-secret audit when secrets existed; a scrub or audit failure rolls the action back. Destructive module retirement applies the same rule before table drop.",
layer="evidence",
documentation_types=("admin",),
audience=("mail_admin", "platform_operator", "security_reviewer", "release_reviewer"),
order=43,
conditions=(
DocumentationCondition(
required_modules=("mail",),
any_scopes=("mail:secret:manage", "mail:profile:write", "system:settings:read"),
),
),
links=(
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Mail profiles API", href="/api/v1/mail/profiles", kind="api"),
),
related_modules=("audit",),
unlocks=("A testable Mail trust boundary with fail-closed egress and auditable secret disposal.",),
metadata={
"kind": "reference",
"route": "/settings?section=mail-profiles",
"screen": "Mail profiles",
"section": "Credentials, network egress, deletion, and retirement",
"verification": "Test DNS rebinding and denied addresses, prove the connected peer is pinned, inject credential-scrub and audit failures, repeat deletion for idempotency, and run the retirement preflight against a snapshot.",
"related_topic_ids": [
"mail.workflow.choose-and-test-profile",
"mail.reference.campaign-delivery-contract",
"campaigns.reference.composition-assurance",
],
},
),
DocumentationTopic(
id="mail.reference.campaign-delivery-contract",
title="Integrate Campaign through the Mail delivery contract",
summary="Campaign freezes a Mail profile reference and opaque revision; Mail re-authorizes, revision-checks, resolves credentials, and performs the effect in one call.",
body="The mail.campaign_delivery 0.2 contract never returns decrypted credentials or resolved SMTP/IMAP configuration. Mail compares the expected random transport revision before decrypting protocol-specific credentials and returns only bounded sanitized outcomes. Campaign owns durable recipient jobs, retry, and reconciliation. Live report emailing remains disabled until the planned Mail-owned idempotent outbox and attempt ledger are implemented.",
layer="available",
documentation_types=("admin", "user"),
audience=("integrator", "campaign_manager", "campaign_sender", "release_reviewer"),
order=44,
conditions=(
DocumentationCondition(
required_modules=("mail", "campaigns"),
required_scopes=("mail:profile:use", "campaigns:campaign:read"),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(label="Mail handbook", href="govoplan-mail/docs/MAIL_HANDBOOK.md", kind="repository"),
DocumentationLink(label="Campaign Mail profile boundary", href="govoplan-campaign/docs/MAIL_PROFILE_BOUNDARY.md", kind="repository"),
),
related_modules=("campaigns", "audit"),
unlocks=("Profile-backed campaign delivery without cross-module credential or configuration copies.",),
metadata={
"kind": "reference",
"route": "/campaigns/{campaign_id}/mail-settings",
"screen": "Campaign Mail settings",
"section": "Mail-owned profile and transport boundary",
"verification": "Prove stale revisions fail before credential decryption, SMTP never decrypts IMAP credentials, IMAP never decrypts SMTP credentials, provider details are sanitized, and the interface/version gate passes.",
"related_topic_ids": [
"mail.profile-ownership-and-consumers",
"campaigns.mail-profile-user-journey",
"campaigns.workflow.retry-and-reconcile",
],
},
),
),
documentation_providers=(documentation_topics,),
)
def get_manifest() -> ModuleManifest:
return manifest