docs: surface custom Mail profile task
This commit is contained in:
@@ -4,15 +4,32 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.modules import DocumentationContext, DocumentationLink, DocumentationTopic
|
||||
from govoplan_mail.backend.mail_profiles import MailProfileError, effective_mail_profile_policy_for_scope
|
||||
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic
|
||||
from govoplan_mail.backend.mail_profiles import (
|
||||
EffectiveMailProfilePolicy,
|
||||
MailProfileError,
|
||||
effective_mail_profile_policy_for_scope,
|
||||
)
|
||||
|
||||
MAIL_POLICY_DOC_SCOPES = ("mail:profile:read", "admin:policies:read", "system:settings:read")
|
||||
MAIL_PROFILE_READ_SCOPE = "mail:profile:read"
|
||||
MAIL_PROFILE_WRITE_SCOPE = "mail:profile:write"
|
||||
MAIL_SECRET_MANAGE_SCOPE = "mail:secret:manage"
|
||||
MAIL_PROFILE_TEST_SCOPE = "mail:profile:test"
|
||||
MAIL_PROFILE_USE_SCOPE = "mail:profile:use"
|
||||
_HOST_POLICY_FIELDS = (("SMTP", "smtp_hosts"), ("IMAP", "imap_hosts"))
|
||||
|
||||
|
||||
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
||||
topic = _tenant_mail_policy_topic(context)
|
||||
return (topic,) if topic is not None else ()
|
||||
topics = [
|
||||
topic
|
||||
for topic in (
|
||||
_tenant_mail_policy_topic(context),
|
||||
_custom_mail_profile_topic(context),
|
||||
)
|
||||
if topic is not None
|
||||
]
|
||||
return tuple(topics)
|
||||
|
||||
|
||||
def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTopic | None:
|
||||
@@ -29,17 +46,26 @@ def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTop
|
||||
try:
|
||||
policy = effective_mail_profile_policy_for_scope(session, tenant_id=tenant_id, scope_type="tenant")
|
||||
except MailProfileError as exc:
|
||||
user_documentation = context.documentation_type == "user"
|
||||
return DocumentationTopic(
|
||||
id="mail.tenant-profile-policy-unavailable",
|
||||
title="Mail server policy could not be evaluated",
|
||||
summary="Mail profile documentation is installed, but the tenant-level effective policy could not be read for this request.",
|
||||
body=str(exc),
|
||||
body=(
|
||||
"The current Mail policy could not be loaded. Try again or ask a Mail administrator for help."
|
||||
if user_documentation
|
||||
else str(exc)
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=(context.documentation_type,),
|
||||
source_module_id="mail",
|
||||
order=41,
|
||||
links=(_mail_policy_api_link(),),
|
||||
metadata={"error_type": type(exc).__name__},
|
||||
links=(
|
||||
(DocumentationLink(label="Public mail help", href="https://govplan.add-ideas.de/modules/mail", kind="public"),)
|
||||
if user_documentation
|
||||
else (_mail_policy_api_link(),)
|
||||
),
|
||||
metadata={} if user_documentation else {"error_type": type(exc).__name__},
|
||||
)
|
||||
|
||||
effective = policy.as_dict()
|
||||
@@ -90,6 +116,237 @@ def _tenant_mail_policy_topic(context: DocumentationContext) -> DocumentationTop
|
||||
)
|
||||
|
||||
|
||||
def _custom_mail_profile_topic(context: DocumentationContext) -> DocumentationTopic | None:
|
||||
if context.documentation_type != "user":
|
||||
return None
|
||||
principal = context.principal
|
||||
if not _has_all_scopes(principal, (MAIL_PROFILE_READ_SCOPE, MAIL_PROFILE_WRITE_SCOPE)):
|
||||
return None
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "")
|
||||
user_id = str(getattr(getattr(principal, "user", None), "id", "") or "")
|
||||
session = context.session
|
||||
if not tenant_id or not user_id or not isinstance(session, Session):
|
||||
return None
|
||||
|
||||
try:
|
||||
policy = effective_mail_profile_policy_for_scope(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_type="user",
|
||||
scope_id=user_id,
|
||||
)
|
||||
except MailProfileError:
|
||||
# A user-facing runtime topic must not turn internal policy resolution
|
||||
# details into documentation output. The task simply remains absent
|
||||
# until its effective policy can be proven.
|
||||
return None
|
||||
if not policy.allow_user_profiles:
|
||||
return None
|
||||
|
||||
can_manage_credentials = _has_any_scope(principal, (MAIL_SECRET_MANAGE_SCOPE,))
|
||||
can_test_profile = _has_all_scopes(principal, (MAIL_PROFILE_TEST_SCOPE, MAIL_PROFILE_USE_SCOPE))
|
||||
can_use_profile = _has_any_scope(principal, (MAIL_PROFILE_USE_SCOPE,))
|
||||
approval_required = bool(policy.allowed_profile_id_sets)
|
||||
constraints = _host_policy_constraint_records(policy)
|
||||
authority_lines = _custom_profile_authority_lines(
|
||||
can_manage_credentials=can_manage_credentials,
|
||||
can_test_profile=can_test_profile,
|
||||
can_use_profile=can_use_profile,
|
||||
approval_required=approval_required,
|
||||
)
|
||||
steps = _custom_profile_steps(
|
||||
can_manage_credentials=can_manage_credentials,
|
||||
can_test_profile=can_test_profile,
|
||||
can_use_profile=can_use_profile,
|
||||
approval_required=approval_required,
|
||||
)
|
||||
return DocumentationTopic(
|
||||
id="mail.workflow.create-custom-profile",
|
||||
title="Create a custom Mail profile",
|
||||
summary=(
|
||||
"Create a reusable profile in the current account's user-scoped Settings view, "
|
||||
"within the active SMTP and IMAP hostname policy."
|
||||
),
|
||||
body="\n".join(authority_lines),
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("mail_profile_author", "campaign_manager"),
|
||||
order=41,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("mail",),
|
||||
required_scopes=(MAIL_PROFILE_READ_SCOPE, MAIL_PROFILE_WRITE_SCOPE),
|
||||
configuration_keys=("mail_profile_policy",),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="My Mail profiles", href="/settings?section=mail-profiles", kind="runtime"),
|
||||
DocumentationLink(label="Public mail help", href="https://govplan.add-ideas.de/modules/mail", kind="public"),
|
||||
),
|
||||
related_modules=("campaigns",),
|
||||
unlocks=("A custom Mail-owned transport definition that authorized tasks can reference after all policy checks pass.",),
|
||||
configuration_keys=("mail_profile_policy",),
|
||||
i18n_key="mail.topic.create_custom_profile",
|
||||
source_module_id="mail",
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/settings?section=mail-profiles",
|
||||
"screen": "My Mail profiles",
|
||||
"help_contexts": ["mail.profiles", "app.settings"],
|
||||
"prerequisites": [
|
||||
"The Mail profile editor is available in Settings and opens in the current account's user scope.",
|
||||
"The effective Mail policy permits user-scoped profiles.",
|
||||
],
|
||||
"steps": list(steps),
|
||||
"outcome": "A user-scoped custom Mail profile is saved without copying its credentials into a consuming module.",
|
||||
"current_configuration": list(authority_lines),
|
||||
"constraints": list(constraints),
|
||||
"verification": _custom_profile_verification(
|
||||
can_test_profile=can_test_profile,
|
||||
can_use_profile=can_use_profile,
|
||||
approval_required=approval_required,
|
||||
),
|
||||
"can_manage_credentials": can_manage_credentials,
|
||||
"can_test_profile": can_test_profile,
|
||||
"can_use_profile": can_use_profile,
|
||||
"approval_required_before_use": approval_required,
|
||||
"related_topic_ids": [
|
||||
"mail.workflow.choose-and-test-profile",
|
||||
"mail.profile-ownership-and-consumers",
|
||||
"mail.profiles-and-policy",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _host_policy_constraint_records(policy: EffectiveMailProfilePolicy) -> tuple[dict[str, Any], ...]:
|
||||
constraints: list[dict[str, Any]] = []
|
||||
for label, key in _HOST_POLICY_FIELDS:
|
||||
prefix = label.casefold()
|
||||
denied = _display_patterns(policy.blacklist_patterns.get(key, []))
|
||||
constraints.append({
|
||||
"id": f"{prefix}-host-deny",
|
||||
"label": f"{label} denied hosts",
|
||||
"description": (
|
||||
"Deny rules are checked first. The hostname must not match any listed pattern."
|
||||
if denied
|
||||
else "Deny rules are checked first. No hostname deny pattern is active."
|
||||
),
|
||||
**({"values": list(denied)} if denied else {}),
|
||||
})
|
||||
allowed_groups = tuple(
|
||||
group
|
||||
for group in (_display_patterns(items) for items in policy.whitelist_groups.get(key, []))
|
||||
if group
|
||||
)
|
||||
if not allowed_groups:
|
||||
constraints.append({
|
||||
"id": f"{prefix}-host-allow",
|
||||
"label": f"{label} allowed hosts",
|
||||
"description": "After deny checks, no hostname allow-list group is active.",
|
||||
})
|
||||
continue
|
||||
for index, group in enumerate(allowed_groups, start=1):
|
||||
constraints.append({
|
||||
"id": f"{prefix}-host-allow-{index}",
|
||||
"label": f"{label} allowed hosts — group {index}",
|
||||
"description": (
|
||||
"After deny checks, the hostname must match at least one pattern in this group. "
|
||||
"It must satisfy every active allow-list group shown for this protocol."
|
||||
),
|
||||
"values": list(group),
|
||||
})
|
||||
return tuple(constraints)
|
||||
|
||||
|
||||
def _display_patterns(patterns: list[str]) -> tuple[str, ...]:
|
||||
result: list[str] = []
|
||||
for pattern in patterns:
|
||||
value = str(pattern).strip()
|
||||
if value and value not in result:
|
||||
result.append(value)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _custom_profile_authority_lines(
|
||||
*,
|
||||
can_manage_credentials: bool,
|
||||
can_test_profile: bool,
|
||||
can_use_profile: bool,
|
||||
approval_required: bool,
|
||||
) -> tuple[str, ...]:
|
||||
credentials = (
|
||||
"Credential authority: you may save or replace Mail-owned SMTP/IMAP passwords."
|
||||
if can_manage_credentials
|
||||
else "Credential authority: you may define the profile, but you cannot save or replace passwords; an actor with both profile-write and secret-management authority must do that when authentication requires one."
|
||||
)
|
||||
testing = (
|
||||
"Test authority: you may run the profile's SMTP/IMAP connection tests after saving it as active."
|
||||
if can_test_profile
|
||||
else "Test authority: creating the profile does not let you run connection tests; ask an actor with both profile-test and profile-use authority to verify an active profile."
|
||||
)
|
||||
use = (
|
||||
"Use authority: you may select the profile in an authorized task after its contextual policy checks pass."
|
||||
if can_use_profile
|
||||
else "Use authority: creating the profile does not let you select or use it; separate Mail profile use authority is required."
|
||||
)
|
||||
approval = (
|
||||
"Approval: an approved-profile list is active. A newly generated profile reference must be approved before the profile can be selected or used."
|
||||
if approval_required
|
||||
else "Approval: no approved-profile list currently blocks a newly created profile, but each consuming task still rechecks its contextual policy."
|
||||
)
|
||||
return credentials, testing, use, approval
|
||||
|
||||
|
||||
def _custom_profile_steps(
|
||||
*,
|
||||
can_manage_credentials: bool,
|
||||
can_test_profile: bool,
|
||||
can_use_profile: bool,
|
||||
approval_required: bool,
|
||||
) -> tuple[str, ...]:
|
||||
steps = [
|
||||
"Open Settings, choose Mail profiles, and select Add profile in the current account's user-scoped view.",
|
||||
"Enter a stable name and configure SMTP plus optional IMAP hostnames that satisfy every host-policy statement shown above.",
|
||||
]
|
||||
steps.append(
|
||||
"Enter the required SMTP/IMAP credentials in the dedicated password fields."
|
||||
if can_manage_credentials
|
||||
else "Save the non-secret profile definition, then ask an actor with both profile-write and secret-management authority to add credentials if the server requires authentication."
|
||||
)
|
||||
steps.append("Save the profile; Mail validates the effective user-scope host policy again on the server.")
|
||||
steps.append(
|
||||
"Save the profile as active, then run the available SMTP and IMAP connection tests."
|
||||
if can_test_profile
|
||||
else "Ask an actor with both profile-test and profile-use authority to run the SMTP and IMAP connection tests after the profile is active."
|
||||
)
|
||||
if approval_required:
|
||||
steps.append("Ask a Mail administrator to add the new profile to the active approved-profile list.")
|
||||
steps.append(
|
||||
"Select the profile from the consuming task's picker and complete that task's contextual validation."
|
||||
if can_use_profile
|
||||
else "Ask an actor with Mail profile use authority to select it in the consuming task after approval and testing."
|
||||
)
|
||||
return tuple(steps)
|
||||
|
||||
|
||||
def _custom_profile_verification(*, can_test_profile: bool, can_use_profile: bool, approval_required: bool) -> str:
|
||||
checks = ["Reopen My Mail profiles and confirm the saved profile remains in the current account's user-scoped view."]
|
||||
checks.append(
|
||||
"Confirm the authorized SMTP/IMAP tests succeed."
|
||||
if can_test_profile
|
||||
else "Have an authorized tester confirm the SMTP/IMAP tests succeed."
|
||||
)
|
||||
if approval_required:
|
||||
checks.append("Confirm an administrator approved the generated profile reference before expecting it in a picker.")
|
||||
checks.append(
|
||||
"Confirm the intended task can select it and passes its own policy validation."
|
||||
if can_use_profile
|
||||
else "Confirm a separately authorized user can select it and passes the consuming task's policy validation."
|
||||
)
|
||||
return " ".join(checks)
|
||||
|
||||
|
||||
def _mail_policy_admin_text(policy: dict[str, Any], *, source_count: int) -> tuple[str, str]:
|
||||
allowed_profile_ids = policy.get("allowed_profile_ids")
|
||||
lower_scopes = _allowed_lower_scopes(policy)
|
||||
@@ -259,5 +516,16 @@ def _has_any_scope(principal: object | None, scopes: tuple[str, ...]) -> bool:
|
||||
return "*" in principal_scopes or any(scope in principal_scopes for scope in scopes)
|
||||
|
||||
|
||||
def _has_all_scopes(principal: object | None, scopes: tuple[str, ...]) -> bool:
|
||||
has = getattr(principal, "has", None)
|
||||
if callable(has):
|
||||
try:
|
||||
return all(bool(has(scope)) for scope in scopes)
|
||||
except Exception:
|
||||
return False
|
||||
principal_scopes = set(getattr(principal, "scopes", ()) or ())
|
||||
return "*" in principal_scopes or all(scope in principal_scopes for scope in scopes)
|
||||
|
||||
|
||||
def _mail_policy_api_link() -> DocumentationLink:
|
||||
return DocumentationLink(label="Tenant mail policy API", href="/api/v1/mail/policies/tenant", kind="api")
|
||||
|
||||
@@ -279,6 +279,7 @@ manifest = ModuleManifest(
|
||||
"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.",
|
||||
@@ -321,6 +322,7 @@ manifest = ModuleManifest(
|
||||
"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.",
|
||||
|
||||
Reference in New Issue
Block a user