docs: surface custom Mail profile task

This commit is contained in:
2026-07-21 18:43:46 +02:00
parent e6a3617a94
commit 193898b00d
4 changed files with 460 additions and 7 deletions

View File

@@ -114,6 +114,10 @@ non-production provider and mailbox first. A successful connection test does
not prove policy authorization for a later Campaign context, deliverability,
recipient acceptance, SPF/DKIM/DMARC alignment, or future availability.
Testing a saved profile requires both `mail:profile:test` and
`mail:profile:use`, and the profile must be active. Profile creation or test
authority alone is not enough.
Raw settings test endpoints accept new settings only for actors who may both
test profiles and manage secrets. They are an administration aid, not a way for
ordinary consumers to bypass reusable profiles.
@@ -149,6 +153,14 @@ profile metadata administrators not to know or replace credentials.
### Create or change a profile
The configured Help Center exposes **Create a custom Mail profile** only when
the current actor has profile-write authority and the effective user-scope
policy permits user profiles. It states the active SMTP/IMAP hostname
allow-list groups and deny rules, plus the actor's separate credential, test,
use, and approval requirements. The Settings task creates in the current
account's user scope; `mail:profile:write` itself remains broad profile
administration authority, not an API-enforced self-only permission.
1. Choose the narrowest suitable scope and a stable, descriptive name/slug.
2. Configure SMTP, optional IMAP, TLS mode, account identity, Sent-folder
behavior, and timeouts. Sender/envelope/recipient constraints belong to

View File

@@ -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")

View File

@@ -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.",

171
tests/test_documentation.py Normal file
View File

@@ -0,0 +1,171 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy.orm import Session
from govoplan_core.core.modules import DocumentationContext
from govoplan_mail.backend.documentation import documentation_topics
from govoplan_mail.backend.mail_profiles import EffectiveMailProfilePolicy, MailProfileError
class _Principal:
tenant_id = "tenant-1"
user = SimpleNamespace(id="user-1")
def __init__(self, scopes: set[str]) -> None:
self.scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self.scopes
class MailRuntimeDocumentationTests(unittest.TestCase):
def setUp(self) -> None:
self.session = Session()
def tearDown(self) -> None:
self.session.close()
def context(self, scopes: set[str], *, documentation_type: str = "user") -> DocumentationContext:
return DocumentationContext(
registry=object(),
principal=_Principal(scopes),
settings=None,
session=self.session,
documentation_type=documentation_type, # type: ignore[arg-type]
)
def topics(self, context: DocumentationContext, user_policy: EffectiveMailProfilePolicy) -> dict[str, object]:
tenant_policy = EffectiveMailProfilePolicy()
def effective_policy(_session, *, scope_type, **_kwargs):
return user_policy if scope_type == "user" else tenant_policy
with patch(
"govoplan_mail.backend.documentation.effective_mail_profile_policy_for_scope",
side_effect=effective_policy,
):
return {topic.id: topic for topic in documentation_topics(context)}
def test_custom_profile_task_requires_write_authority_and_enabled_user_profiles(self) -> None:
enabled = EffectiveMailProfilePolicy(allow_user_profiles=True)
without_write = self.topics(self.context({"mail:profile:read"}), enabled)
self.assertNotIn("mail.workflow.create-custom-profile", without_write)
without_read = self.topics(self.context({"mail:profile:write"}), enabled)
self.assertNotIn("mail.workflow.create-custom-profile", without_read)
disabled = EffectiveMailProfilePolicy(allow_user_profiles=False)
blocked = self.topics(self.context({"mail:profile:read", "mail:profile:write"}), disabled)
self.assertNotIn("mail.workflow.create-custom-profile", blocked)
available = self.topics(self.context({"mail:profile:read", "mail:profile:write"}), enabled)
self.assertIn("mail.workflow.create-custom-profile", available)
task = available["mail.workflow.create-custom-profile"]
self.assertEqual(task.title, "Create a custom Mail profile")
self.assertEqual(task.metadata["route"], "/settings?section=mail-profiles")
self.assertEqual(task.metadata["help_contexts"], ["mail.profiles", "app.settings"])
self.assertIn("current account's user scope", task.metadata["prerequisites"][0])
def test_custom_profile_task_distinguishes_secret_test_and_use_authority(self) -> None:
policy = EffectiveMailProfilePolicy()
cases = (
(set(), False, False, False),
({"mail:secret:manage"}, True, False, False),
({"mail:profile:test"}, False, False, False),
({"mail:profile:use"}, False, False, True),
({"mail:profile:test", "mail:profile:use"}, False, True, True),
({"mail:secret:manage", "mail:profile:test", "mail:profile:use"}, True, True, True),
)
for extra_scopes, credentials, testing, use in cases:
with self.subTest(extra_scopes=extra_scopes):
topics = self.topics(self.context({"mail:profile:read", "mail:profile:write", *extra_scopes}), policy)
task = topics["mail.workflow.create-custom-profile"]
self.assertEqual(task.metadata["can_manage_credentials"], credentials)
self.assertEqual(task.metadata["can_test_profile"], testing)
self.assertEqual(task.metadata["can_use_profile"], use)
self.assertIn(
"you may save or replace" if credentials else "you cannot save or replace passwords",
task.body,
)
self.assertIn(
"you may run" if testing else "does not let you run connection tests",
task.body,
)
self.assertIn(
"you may select" if use else "does not let you select or use it",
task.body,
)
def test_custom_profile_task_preserves_host_allow_group_and_deny_precedence(self) -> None:
policy = EffectiveMailProfilePolicy(
whitelist_groups={
"smtp_hosts": [
["*.example.edu", "smtp.shared.test"],
["smtp-*.example.edu"],
],
"imap_hosts": [["imap.example.edu"]],
},
blacklist_patterns={
"smtp_hosts": ["smtp-blocked.example.edu"],
"imap_hosts": ["imap-legacy.example.edu"],
},
allowed_profile_id_sets=[{"private-profile-id"}],
)
topics = self.topics(
self.context({"mail:profile:read", "mail:profile:write", "mail:secret:manage", "mail:profile:test", "mail:profile:use"}),
policy,
)
task = topics["mail.workflow.create-custom-profile"]
constraints = {item["id"]: item for item in task.metadata["constraints"]}
constraints_text = " ".join(item["description"] for item in task.metadata["constraints"])
self.assertIn("Deny rules are checked first", constraints_text)
self.assertEqual(constraints["smtp-host-deny"]["values"], ["smtp-blocked.example.edu"])
self.assertEqual(constraints["smtp-host-allow-1"]["values"], ["*.example.edu", "smtp.shared.test"])
self.assertEqual(constraints["smtp-host-allow-2"]["values"], ["smtp-*.example.edu"])
self.assertIn("every active allow-list group", constraints_text)
self.assertEqual(constraints["imap-host-allow-1"]["values"], ["imap.example.edu"])
self.assertTrue(task.metadata["approval_required_before_use"])
self.assertIn("must be approved before the profile can be selected or used", task.body)
self.assertNotIn("private-profile-id", repr(task))
self.assertIn("every active allow-list group", constraints["smtp-host-allow-1"]["description"])
def test_custom_profile_task_explains_when_no_approval_list_is_active(self) -> None:
topics = self.topics(
self.context({"mail:profile:read", "mail:profile:write", "mail:profile:use"}),
EffectiveMailProfilePolicy(),
)
task = topics["mail.workflow.create-custom-profile"]
self.assertFalse(task.metadata["approval_required_before_use"])
self.assertIn("no approved-profile list currently blocks", task.body)
def test_user_policy_errors_are_generic_and_never_create_a_task(self) -> None:
context = self.context({"mail:profile:read", "mail:profile:write"})
with patch(
"govoplan_mail.backend.documentation.effective_mail_profile_policy_for_scope",
side_effect=MailProfileError("sensitive source-id profile-id username secret"),
):
topics = {topic.id: topic for topic in documentation_topics(context)}
self.assertNotIn("mail.workflow.create-custom-profile", topics)
unavailable = topics["mail.tenant-profile-policy-unavailable"]
self.assertEqual(
unavailable.body,
"The current Mail policy could not be loaded. Try again or ask a Mail administrator for help.",
)
self.assertNotIn("sensitive", repr(unavailable))
def test_manifest_workflows_declare_contextual_help_targets(self) -> None:
from govoplan_mail.backend.manifest import get_manifest
topics = {topic.id: topic for topic in get_manifest().documentation}
self.assertEqual(topics["mail.workflow.choose-and-test-profile"].metadata["help_contexts"], ["mail.profiles", "app.settings"])
self.assertEqual(topics["mail.workflow.read-mailbox"].metadata["help_contexts"], ["mail.list"])
if __name__ == "__main__":
unittest.main()