diff --git a/docs/MAIL_HANDBOOK.md b/docs/MAIL_HANDBOOK.md index 1818734..283baa6 100644 --- a/docs/MAIL_HANDBOOK.md +++ b/docs/MAIL_HANDBOOK.md @@ -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 diff --git a/src/govoplan_mail/backend/documentation.py b/src/govoplan_mail/backend/documentation.py index 779f5ad..aa33ac1 100644 --- a/src/govoplan_mail/backend/documentation.py +++ b/src/govoplan_mail/backend/documentation.py @@ -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") diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index 7361155..0ec71ca 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -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.", diff --git a/tests/test_documentation.py b/tests/test_documentation.py new file mode 100644 index 0000000..772ea61 --- /dev/null +++ b/tests/test_documentation.py @@ -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()