From 7844d9cab45c40573f7e490e26d5ac3fe2514154 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 3 Aug 2026 10:06:13 +0200 Subject: [PATCH] Migrate Mail surfaces to interface patterns --- README.md | 3 + docs/INTERFACE_PATTERN_MIGRATION.md | 68 ++++++ docs/MAIL_HANDBOOK.md | 19 ++ src/govoplan_mail/backend/manifest.py | 5 +- tests/test_documentation.py | 4 +- tests/test_manifest.py | 6 + webui/package.json | 2 +- .../test-interface-pattern-language.mjs | 50 ++++ webui/src/features/mail/MailBouncePage.tsx | 73 ++++-- .../features/mail/MailProfileManagement.tsx | 230 ++++++++++++++++-- webui/src/features/mail/MailboxPage.tsx | 40 ++- webui/src/styles/mail-profiles.css | 4 + 12 files changed, 460 insertions(+), 44 deletions(-) create mode 100644 docs/INTERFACE_PATTERN_MIGRATION.md create mode 100644 webui/scripts/test-interface-pattern-language.mjs diff --git a/README.md b/README.md index 0f60f88..b1b0c35 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,9 @@ only for explicit legacy-download requirements. Platform RBAC and governance rules are documented in `govoplan-core/docs/`. The [Mail handbook](docs/MAIL_HANDBOOK.md) provides the adaptive user, governance, technical, security, and operations perspectives. +The [Mail interface pattern inventory](docs/INTERFACE_PATTERN_MIGRATION.md) +records the route, administration, state, accessibility, consequence, and +privacy contracts for the Mail WebUI. ## Release packaging diff --git a/docs/INTERFACE_PATTERN_MIGRATION.md b/docs/INTERFACE_PATTERN_MIGRATION.md new file mode 100644 index 0000000..5e7739f --- /dev/null +++ b/docs/INTERFACE_PATTERN_MIGRATION.md @@ -0,0 +1,68 @@ +# Mail Interface Pattern Migration + +This inventory records the Mail-owned part of the GovOPlaN interface pattern +language. Core owns the shell and shared components; Mail owns the transport, +mailbox, policy, and delivery-evidence consequences described here. + +## Surface inventory + +| Surface | Primary task | Archetype | Consequence | Pattern evidence | +| --- | --- | --- | --- | --- | +| `/mail` folder, message, and preview panes | Browse and inspect an authorized mailbox without changing provider state | Directory/explorer | Medium because message metadata and content are private, although navigation is read-only | Full-height three-pane workspace, bounded paging, stable keyboard selection, contextual Help Center link, explicit no-profile blocker | +| `/mail` toolbar, page filter, and pagination | Select a profile, refresh bounded indexes, and find a message on the current page | Explorer actions and local filtering | Low for refresh; medium for provider access | Shared actions expose loading/profile/folder blockers; profile transport summary is non-secret; loading and errors use Core components | +| System/tenant/group/user/campaign profile surfaces | Compare profiles, protocol servers, reusable credentials, status, and scope | Administration/configuration | High because endpoints, credentials, and inheritance control external communication | Shared `ConnectionTree`, stable row actions, textual status, permission/target blockers, and contextual admin help | +| Profile creation and focused profile/server/credential editors | Create a governed transport identity or edit one hierarchy object | Guided setup plus adaptive create/edit | High because saving may enable provider access or replace encrypted credentials | Shared `Dialog` and `StageRail` for multi-object setup; focused edit modes show only the selected hierarchy object; field help, connection tests, unsaved-draft guard, and disabled-save reasons | +| Mail profile policy card | Narrow visible profiles, lower-scope definitions, hosts, senders, and recipients | Effective-policy editor | High because inherited allow/deny rules govern delivery and lower scopes | Shared policy rows, typed selectors, source path, locked/read-only blocker, dirty-save state, and contextual policy help | +| `/mail/bounces` watcher table | Configure bounded IMAP evidence sources and run an explicit scan | Operational administration | High because it accesses a provider mailbox and changes durable evidence cursors | Shared `DataGrid`, status, loading/error feedback, field help, actionable no-profile blocker, and stable row actions | +| `/mail/bounces` observation table | Inspect correlated or unmatched delivery-status evidence | Evidence/reporting | Medium because recipient and diagnostic data may be sensitive | Bounded sanitized rows, textual status, filters, correlation state, and no raw bounce body | +| Bounce watcher removal | Stop future scans while retaining evidence | Destructive confirmation | Medium and reversible by recreating the watcher; observations are retained | Shared `ConfirmDialog` states the immediate consequence and retained evidence | +| `mail.profiles` and credential-reference capabilities | Let another module select or validate Mail-owned transport without receiving secrets | Governed capability composition | High because the selected identity can perform external effects | Stable references and Core capability boundaries; no sibling-private WebUI import; authorization and credential resolution remain Mail-owned | + +## State and consequence contract + +- Loading, success, error, empty results, permission blockers, and destructive + confirmation use Core components. Mail does not reproduce the shell. +- A target-dependent profile surface cannot load until a concrete user, group, + or campaign is selected. The blocker identifies the responsible actor and + destination instead of silently hiding the editor. +- Profile, server, credential, policy, mailbox refresh, connection-test, and + bounce actions remain visible when structurally relevant. Missing authority, + required input, or an in-progress operation is exposed through a focusable + disabled-action explanation. +- Connection tests never save the draft and explicitly require the relevant + hostname. Save remains the committing action. Policy save is unavailable + until a local change exists. +- Effective policy keeps inherited source/provenance visible. A locked parent + limit or governed workflow cannot be represented as an editable local value. +- Removing a bounce watcher retains observations and delivery evidence. + Profile/server deactivation and credential unlinking use confirmations whose + copy distinguishes retained reusable credentials from scrubbed owned secrets. +- Mailbox browsing is read-only. Listing or previewing must not mark messages + read, move, delete, reply, or expose unbounded content. + +## Accessibility, responsive, and privacy evidence + +Shared `Dialog` owns focus entry, Escape handling, focus containment, and focus +return. Toolbar and form DOM order is keyboard order; mailbox rows support +Enter and Space, arrow navigation is bounded to the visible page, and disabled +reasons are keyboard-focusable. Status always has text in addition to color. +Contextual links identify their destination to assistive technology. + +Profile/policy grids collapse to one column below 900 px. The mailbox changes +from three panes to two below 1250 px and to a single-column toolbar and message +rows below 760 px while preserving source order and independent scroll regions. +Long identities and transport summaries wrap or ellipsize inside stable bounds. + +Profile and mailbox APIs return non-secret transport metadata and bounded +message content only. Passwords are write-only and rendered only as a saved +state marker. Bounce observations contain bounded sanitized diagnostics and a +raw digest, not the raw provider message. Optional Campaign, Addresses, Audit, +Calendar, and Notifications integrations remain capability-driven; Mail WebUI +does not import their private packages. + +The focused structural test guards shared components, contextual help, +actionable blockers, confirmation, optional-module boundaries, responsive +rules, and the absence of browser-native confirmation. Mail backend tests, +Core component tests, manifest-shape checks, module permutations, structural +localization audit, theme contract, and bundle budgets provide integration +evidence. diff --git a/docs/MAIL_HANDBOOK.md b/docs/MAIL_HANDBOOK.md index 70e10d8..04d1691 100644 --- a/docs/MAIL_HANDBOOK.md +++ b/docs/MAIL_HANDBOOK.md @@ -43,6 +43,25 @@ session primitives, cryptographic secret helpers, audit infrastructure, and the module registry. Optional consumers provide narrow context through capabilities; Mail does not import their ORM or service implementations. +## Interface patterns and unavailable actions + +Mail uses the platform's shared explorer, connection tree, adaptive form, +effective-policy, dialog, status, alert, and confirmation components. The +module-owned surface inventory and consequence classification are recorded in +[Mail interface pattern migration](INTERFACE_PATTERN_MIGRATION.md). + +An unavailable action remains visible when it belongs to the current task. Its +hover/focus explanation identifies the missing field, active operation, or +permission. Larger blockers state what must change, who can change it, and +where to go. Contextual Help Center links resolve to the configured Docs module +when installed and otherwise use the public documentation fallback. + +Mailbox reads, profile reloads, connection tests, policy saves, bounce scans, +and destructive actions each expose their current loading or blocked state. +Removing a bounce watcher and deactivating profile hierarchy items use shared +confirmation dialogs. Removing a watcher retains existing observations; +deactivating a profile may scrub Mail-owned credentials as described below. + ## Concepts ### Profile diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index d04d2c1..5c1960a 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -435,6 +435,7 @@ manifest = ModuleManifest( "route": "/settings?section=mail-profiles", "screen": "Mail profiles and policy", "section": "Effective profile policy", + "help_contexts": ["mail.profiles", "mail.admin.profiles"], "related_topic_ids": [ "mail.profile-ownership-and-consumers", "campaigns.mail-profile-governance", @@ -469,6 +470,8 @@ manifest = ModuleManifest( metadata={ "kind": "workflow", "route": "/mail/bounces", + "screen": "Bounce processing", + "help_contexts": ["mail.bounces", "mail.bounce-processing"], "verification": ( "Send a message with a unique Message-ID, ingest a DSN twice, and " "verify one correlated observation while the SMTP acceptance remains intact." @@ -583,7 +586,7 @@ manifest = ModuleManifest( "kind": "workflow", "route": "/mail", "screen": "Mail", - "help_contexts": ["mail.list"], + "help_contexts": ["mail.list", "mail.mailbox"], "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 index 7272c3a..23b8c3d 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -182,7 +182,9 @@ class MailRuntimeDocumentationTests(unittest.TestCase): 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"]) + self.assertEqual(topics["mail.workflow.read-mailbox"].metadata["help_contexts"], ["mail.list", "mail.mailbox"]) + self.assertIn("mail.admin.profiles", topics["mail.profiles-and-policy"].metadata["help_contexts"]) + self.assertIn("mail.bounce-processing", topics["mail.bounce-processing"].metadata["help_contexts"]) def test_configuration_provider_exposes_only_explicit_or_inherited_state(self) -> None: inherited = EffectiveMailProfilePolicy( diff --git a/tests/test_manifest.py b/tests/test_manifest.py index ee05c5f..02877a4 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -71,6 +71,7 @@ class MailManifestTests(unittest.TestCase): mailbox = topics["mail.workflow.read-mailbox"] self.assertEqual(mailbox.metadata["route"], "/mail") + self.assertIn("mail.mailbox", mailbox.metadata["help_contexts"]) self.assertEqual( mailbox.conditions[0].required_scopes, ("mail:mailbox:read", "mail:profile:use"), @@ -79,6 +80,11 @@ class MailManifestTests(unittest.TestCase): campaign_contract = topics["mail.reference.campaign-delivery-contract"] self.assertEqual(campaign_contract.conditions[0].required_modules, ("mail", "campaigns")) + profile_policy = topics["mail.profiles-and-policy"] + self.assertIn("mail.admin.profiles", profile_policy.metadata["help_contexts"]) + bounce_processing = topics["mail.bounce-processing"] + self.assertIn("mail.bounce-processing", bounce_processing.metadata["help_contexts"]) + def test_retirement_scrubs_credentials_before_dropping_mail_tables(self) -> None: events: list[str] = [] diff --git a/webui/package.json b/webui/package.json index 645b885..9f3643a 100644 --- a/webui/package.json +++ b/webui/package.json @@ -26,7 +26,7 @@ } }, "scripts": { - "test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs" + "test:mail-ui": "rm -rf .mail-test-build && mkdir -p .mail-test-build && printf '{\"type\":\"commonjs\"}\\n' > .mail-test-build/package.json && tsc -p tsconfig.mail-tests.json && node .mail-test-build/tests/mailbox-folders.test.js && node .mail-test-build/tests/mail-profile-editor-model.test.js && node .mail-test-build/tests/mail-policy-validation.test.js && node scripts/test-mailbox-icon-button-structure.mjs && node scripts/test-interface-pattern-language.mjs" }, "devDependencies": { "typescript": "^5.7.2" diff --git a/webui/scripts/test-interface-pattern-language.mjs b/webui/scripts/test-interface-pattern-language.mjs new file mode 100644 index 0000000..75c3e4e --- /dev/null +++ b/webui/scripts/test-interface-pattern-language.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +function read(relativePath) { + return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); +} + +const profiles = read("../src/features/mail/MailProfileManagement.tsx"); +const mailbox = read("../src/features/mail/MailboxPage.tsx"); +const bounces = read("../src/features/mail/MailBouncePage.tsx"); +const moduleSource = read("../src/module.ts"); +const styles = read("../src/styles/mail-profiles.css"); +const migration = read("../../docs/INTERFACE_PATTERN_MIGRATION.md"); + +assert.match(profiles, /ActionBlockerHint,[\s\S]*DocumentationHelpLink/); +for (const sharedComponent of ["ConnectionTree", "ConfirmDialog", "Dialog", "LoadingFrame"]) { + assert.match(profiles, new RegExp(`\\b${sharedComponent}\\b`)); +} +assert.match(profiles, /topicId: "mail\.profiles-and-policy"/); +assert.match(profiles, /disabledReason: credentialMutationBlocker/); +assert.match(profiles, /disabledReason=\{editorSaveBlocker\}/); +assert.match(profiles, /disabledReason=\{policySaveBlocker\}/); +assert.match(profiles, /smtpActionDisabledReason=\{smtpTestBlocker\}/); + +assert.match(mailbox, /ActionBlockerHint/); +assert.match(mailbox, /DocumentationHelpLink/); +assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/); +assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/); +assert.match(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/); + +assert.match(bounces, /DocumentationHelpLink/); +assert.match(bounces, /topicId: "mail\.bounce-processing"/); +assert.match(bounces, /([]); @@ -50,6 +58,24 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings }) () => new Map(profiles.map((profile) => [profile.id, profile.name])), [profiles] ); + const imapProfiles = useMemo( + () => profiles.filter((profile) => profile.is_active && profile.imap), + [profiles] + ); + const pageMutationBlocker = loading + ? "Bounce evidence is already loading." + : busy + ? "Wait for the current bounce-processing action to finish." + : ""; + const addWatcherBlocker = pageMutationBlocker + || (imapProfiles.length === 0 ? "Configure an active IMAP-enabled Mail profile before adding a watcher." : ""); + const saveWatcherBlocker = busy + ? "Wait for the current bounce-processing action to finish." + : !profileId + ? "Select an active IMAP-enabled Mail profile." + : !folder.trim() + ? "Enter the mailbox folder that contains delivery-status messages." + : ""; async function load() { setLoading(true); @@ -153,8 +179,8 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings }) width: 92, sticky: "end", render: (source) =>