Migrate Mail surfaces to interface patterns

This commit is contained in:
2026-08-03 10:06:13 +02:00
parent 5b5077cde7
commit 7844d9cab4
12 changed files with 460 additions and 44 deletions
+3
View File
@@ -130,6 +130,9 @@ only for explicit legacy-download requirements.
Platform RBAC and governance rules are documented in `govoplan-core/docs/`. Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
The [Mail handbook](docs/MAIL_HANDBOOK.md) provides the adaptive user, The [Mail handbook](docs/MAIL_HANDBOOK.md) provides the adaptive user,
governance, technical, security, and operations perspectives. 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 ## Release packaging
+68
View File
@@ -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.
+19
View File
@@ -43,6 +43,25 @@ session primitives, cryptographic secret helpers, audit infrastructure, and the
module registry. Optional consumers provide narrow context through capabilities; module registry. Optional consumers provide narrow context through capabilities;
Mail does not import their ORM or service implementations. 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 ## Concepts
### Profile ### Profile
+4 -1
View File
@@ -435,6 +435,7 @@ manifest = ModuleManifest(
"route": "/settings?section=mail-profiles", "route": "/settings?section=mail-profiles",
"screen": "Mail profiles and policy", "screen": "Mail profiles and policy",
"section": "Effective profile policy", "section": "Effective profile policy",
"help_contexts": ["mail.profiles", "mail.admin.profiles"],
"related_topic_ids": [ "related_topic_ids": [
"mail.profile-ownership-and-consumers", "mail.profile-ownership-and-consumers",
"campaigns.mail-profile-governance", "campaigns.mail-profile-governance",
@@ -469,6 +470,8 @@ manifest = ModuleManifest(
metadata={ metadata={
"kind": "workflow", "kind": "workflow",
"route": "/mail/bounces", "route": "/mail/bounces",
"screen": "Bounce processing",
"help_contexts": ["mail.bounces", "mail.bounce-processing"],
"verification": ( "verification": (
"Send a message with a unique Message-ID, ingest a DSN twice, and " "Send a message with a unique Message-ID, ingest a DSN twice, and "
"verify one correlated observation while the SMTP acceptance remains intact." "verify one correlated observation while the SMTP acceptance remains intact."
@@ -583,7 +586,7 @@ manifest = ModuleManifest(
"kind": "workflow", "kind": "workflow",
"route": "/mail", "route": "/mail",
"screen": "Mail", "screen": "Mail",
"help_contexts": ["mail.list"], "help_contexts": ["mail.list", "mail.mailbox"],
"prerequisites": [ "prerequisites": [
"An active visible profile has IMAP configured.", "An active visible profile has IMAP configured.",
"You may both use that profile and read its mailbox.", "You may both use that profile and read its mailbox.",
+3 -1
View File
@@ -182,7 +182,9 @@ class MailRuntimeDocumentationTests(unittest.TestCase):
topics = {topic.id: topic for topic in get_manifest().documentation} 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.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: def test_configuration_provider_exposes_only_explicit_or_inherited_state(self) -> None:
inherited = EffectiveMailProfilePolicy( inherited = EffectiveMailProfilePolicy(
+6
View File
@@ -71,6 +71,7 @@ class MailManifestTests(unittest.TestCase):
mailbox = topics["mail.workflow.read-mailbox"] mailbox = topics["mail.workflow.read-mailbox"]
self.assertEqual(mailbox.metadata["route"], "/mail") self.assertEqual(mailbox.metadata["route"], "/mail")
self.assertIn("mail.mailbox", mailbox.metadata["help_contexts"])
self.assertEqual( self.assertEqual(
mailbox.conditions[0].required_scopes, mailbox.conditions[0].required_scopes,
("mail:mailbox:read", "mail:profile:use"), ("mail:mailbox:read", "mail:profile:use"),
@@ -79,6 +80,11 @@ class MailManifestTests(unittest.TestCase):
campaign_contract = topics["mail.reference.campaign-delivery-contract"] campaign_contract = topics["mail.reference.campaign-delivery-contract"]
self.assertEqual(campaign_contract.conditions[0].required_modules, ("mail", "campaigns")) 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: def test_retirement_scrubs_credentials_before_dropping_mail_tables(self) -> None:
events: list[str] = [] events: list[str] = []
+1 -1
View File
@@ -26,7 +26,7 @@
} }
}, },
"scripts": { "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": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
@@ -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, /<ConfirmDialog[\s\S]*confirmLabel="Remove watcher"[\s\S]*tone="danger"/);
assert.match(bounces, /disabledReason=\{saveWatcherBlocker\}/);
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}`, /window\.(?:alert|confirm)\(/);
assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${moduleSource}`, /@govoplan\/(?:campaign|files|docs|calendar)-webui|govoplan_(?:campaign|files|docs|calendar)/);
assert.match(moduleSource, /"mail\.profiles"/);
assert.match(styles, /@media \(max-width: 900px\)[\s\S]*\.mail-profile-transport-summary[\s\S]*grid-template-columns: 1fr/);
assert.match(styles, /@media \(max-width: 1250px\)[\s\S]*\.mailbox-shell\.file-manager-shell[\s\S]*grid-template-columns:/);
assert.match(styles, /@media \(max-width: 760px\)[\s\S]*\.mailbox-toolbar\.file-manager-toolbar[\s\S]*grid-template-columns: 1fr/);
for (const archetype of ["Directory/explorer", "Administration/configuration", "Effective-policy editor", "Evidence/reporting"]) {
assert.match(migration, new RegExp(archetype.replace("/", "\\/")));
}
assert.match(migration, /Shared `Dialog` owns focus entry, Escape handling, focus containment, and focus\s+return/);
assert.match(migration, /Passwords are write-only[\s\S]*saved\s+state marker/);
console.log("Mail surfaces satisfy the recorded interface pattern-language contract.");
+58 -15
View File
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react"; import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
import { import {
ActionBlockerHint,
Button, Button,
Card, Card,
ConfirmDialog,
DataGrid, DataGrid,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
DocumentationHelpLink,
FormField, FormField,
LoadingFrame, LoadingFrame,
PageScrollViewport, PageScrollViewport,
@@ -31,6 +34,11 @@ import {
type MailServerProfile type MailServerProfile
} from "../../api/mail"; } from "../../api/mail";
const MAIL_BOUNCE_DOCUMENTATION = {
topicId: "mail.bounce-processing",
documentationType: "admin"
} as const;
export default function MailBouncePage({ settings }: { settings: ApiSettings }) { export default function MailBouncePage({ settings }: { settings: ApiSettings }) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const [sources, setSources] = useState<MailBounceSource[]>([]); const [sources, setSources] = useState<MailBounceSource[]>([]);
@@ -50,6 +58,24 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
() => new Map(profiles.map((profile) => [profile.id, profile.name])), () => new Map(profiles.map((profile) => [profile.id, profile.name])),
[profiles] [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() { async function load() {
setLoading(true); setLoading(true);
@@ -153,8 +179,8 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
width: 92, width: 92,
sticky: "end", sticky: "end",
render: (source) => <TableActionGroup actions={[ render: (source) => <TableActionGroup actions={[
{ id: "scan", label: "Scan now", icon: <RotateCw aria-hidden="true" />, disabled: Boolean(busy), onClick: () => void scan(source) }, { id: "scan", label: "Scan now", icon: <RotateCw aria-hidden="true" />, disabled: Boolean(busy), disabledReason: busy === `scan:${source.id}` ? "This mailbox scan is already running." : busy ? "Wait for the current bounce-processing action to finish." : "", onClick: () => void scan(source) },
{ id: "delete", label: "Remove watcher", icon: <Trash2 aria-hidden="true" />, variant: "danger", disabled: Boolean(busy), onClick: () => setDeleteSource(source) } { id: "delete", label: "Remove watcher", icon: <Trash2 aria-hidden="true" />, variant: "danger", disabled: Boolean(busy), disabledReason: busy ? "Wait for the current bounce-processing action to finish." : "", onClick: () => setDeleteSource(source) }
]} /> ]} />
} }
]; ];
@@ -174,8 +200,9 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
<div><PageTitle loading={loading}>Bounce processing</PageTitle><p>Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands.</p></div> <div><PageTitle loading={loading}>Bounce processing</PageTitle><p>Watch IMAP delivery-status folders and correlate recipient failures with Mail delivery commands.</p></div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button> <Button onClick={() => navigate("/mail")}><ArrowLeft size={16} aria-hidden="true" /> Mailbox</Button>
<Button onClick={() => void load()} disabled={loading}><RefreshCw size={16} aria-hidden="true" /> Reload</Button> <DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={loading}><Plus size={16} aria-hidden="true" /> Add watcher</Button> <Button onClick={() => void load()} disabled={Boolean(pageMutationBlocker)} disabledReason={pageMutationBlocker}><RefreshCw size={16} aria-hidden="true" /> Reload</Button>
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={Boolean(addWatcherBlocker)} disabledReason={addWatcherBlocker}><Plus size={16} aria-hidden="true" /> Add watcher</Button>
</div> </div>
</div> </div>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
@@ -192,25 +219,41 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
</LoadingFrame> </LoadingFrame>
</div> </div>
<Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(busy) || !profileId || !folder.trim()}>Add watcher</Button></>}> <Dialog open={addOpen} title="Add bounce mailbox watcher" onClose={() => !busy && setAddOpen(false)} footer={<><Button onClick={() => setAddOpen(false)} disabled={Boolean(busy)} disabledReason={busy ? "Wait for the current bounce-processing action to finish." : undefined}>Cancel</Button><Button variant="primary" onClick={() => void addSource()} disabled={Boolean(saveWatcherBlocker)} disabledReason={saveWatcherBlocker}>Add watcher</Button></>}>
<div className="form-grid"> <div className="form-grid">
<FormField label="Mail profile"> {imapProfiles.length === 0 &&
<select value={profileId} onChange={(event) => setProfileId(event.target.value)}> <ActionBlockerHint
reason={{
summary: "No active IMAP-enabled Mail profile can be watched.",
details: "Bounce processing reads a bounded authorized mailbox folder and cannot run without IMAP configuration.",
requiredAction: "Create or activate an IMAP server and credential first.",
actor: "Mail profile administrator",
target: "Settings or Administration > Mail profiles"
}}
documentation={MAIL_BOUNCE_DOCUMENTATION} />
}
<FormField label="Mail profile" documentation={MAIL_BOUNCE_DOCUMENTATION}>
<select value={profileId} disabled={Boolean(busy)} onChange={(event) => setProfileId(event.target.value)}>
<option value="">Select an IMAP profile</option> <option value="">Select an IMAP profile</option>
{profiles.filter((profile) => profile.imap).map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)} {imapProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select> </select>
</FormField> </FormField>
<FormField label="Bounce folder"> <FormField label="Bounce folder" documentation={MAIL_BOUNCE_DOCUMENTATION}>
<input value={folder} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" /> <input value={folder} disabled={Boolean(busy)} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
</FormField> </FormField>
<ToggleSwitch checked={active} onChange={setActive} label="Watch automatically" /> <ToggleSwitch checked={active} disabled={Boolean(busy)} onChange={setActive} label="Watch automatically" />
</div> </div>
</Dialog> </Dialog>
<Dialog open={deleteSource !== null} title="Remove bounce mailbox watcher" onClose={() => !busy && setDeleteSource(null)} footer={<><Button onClick={() => setDeleteSource(null)} disabled={Boolean(busy)}>Cancel</Button><Button variant="danger" onClick={() => void removeSource()} disabled={Boolean(busy)}>Remove</Button></>}> <ConfirmDialog
<p>The watcher will stop scanning this folder. Existing bounce observations and delivery evidence remain available.</p> open={deleteSource !== null}
</Dialog> title="Remove bounce mailbox watcher"
message="The watcher will stop scanning this folder. Existing bounce observations and delivery evidence remain available."
confirmLabel="Remove watcher"
tone="danger"
busy={Boolean(busy)}
onCancel={() => setDeleteSource(null)}
onConfirm={() => void removeSource()} />
</PageScrollViewport> </PageScrollViewport>
); );
} }
+207 -23
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { AdminSelectionList, ConnectionTree, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui"; import { ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
import { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react"; import { ArrowLeft, ArrowRight, Inbox, KeyRound, Link2, Pencil, Plus, Send, Settings2, Trash2, Unlink } from "lucide-react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { import {
@@ -147,6 +147,16 @@ type PendingHierarchyRemoval =
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[]; const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
const MAIL_PROFILE_DOCUMENTATION = {
topicId: "mail.profiles-and-policy",
documentationType: "admin"
} as const;
const MAIL_PROFILE_WORKFLOW_DOCUMENTATION = {
topicId: "mail.workflow.choose-and-test-profile",
documentationType: "user"
} as const;
const patternLabels: Record<MailProfilePatternKey, string> = { const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "i18n:govoplan-mail.smtp_hostnames.36eb51d8", smtp_hosts: "i18n:govoplan-mail.smtp_hostnames.36eb51d8",
imap_hosts: "i18n:govoplan-mail.imap_hostnames.ac9c1d78", imap_hosts: "i18n:govoplan-mail.imap_hostnames.ac9c1d78",
@@ -214,6 +224,24 @@ export function MailProfileScopeManager({
createStage, createStage,
draft draft
); );
const profileMutationBlocker = mailProfileMutationDisabledReason(canWriteProfiles, busy);
const profileReloadBlocker = loading
? "Mail profiles are already loading."
: busy
? "Wait for the current Mail profile change to finish."
: "";
const createStageBlocker = mailProfileCreateStageDisabledReason(
canWriteProfiles,
busy,
createStage,
createStageCanContinue
);
const editorSaveBlocker = mailProfileEditorDisabledReason(
canWriteProfiles,
busy,
scopeReady,
profileEditorCanSave(draft, editing, editingTarget, reuseCredentialId)
);
useUnsavedDraftGuard({ useUnsavedDraftGuard({
dirty: profileDirty, dirty: profileDirty,
@@ -548,19 +576,24 @@ export function MailProfileScopeManager({
function renderMailProfileActions(row: MailProfileTreeRow) { function renderMailProfileActions(row: MailProfileTreeRow) {
if (row.kind === "server") { if (row.kind === "server") {
const label = `Edit ${row.protocol.toUpperCase()} server`; const label = `Edit ${row.protocol.toUpperCase()} server`;
const credentialMutationBlocker = mailCredentialMutationDisabledReason(canWriteProfiles, canManageCredentials, busy);
const serverMutationBlocker = mailProfileMutationDisabledReason(canWriteProfiles, busy);
const serverDeactivationBlocker = serverMutationBlocker || (!row.server.is_active ? "This mail server is already inactive." : "");
return <TableActionGroup actions={[ return <TableActionGroup actions={[
{ {
id: "add-credential", id: "add-credential",
label: "Add or link credential", label: "Add or link credential",
icon: <Link2 size={16} />, icon: <Link2 size={16} />,
disabled: !canWriteProfiles || !canManageCredentials || busy, disabled: Boolean(credentialMutationBlocker),
disabledReason: credentialMutationBlocker,
onClick: () => openCreateCredential(row.profile, row.server) onClick: () => openCreateCredential(row.profile, row.server)
}, },
{ {
id: "edit-server", id: "edit-server",
label, label,
icon: <Pencil size={16} />, icon: <Pencil size={16} />,
disabled: !canWriteProfiles || busy, disabled: Boolean(serverMutationBlocker),
disabledReason: serverMutationBlocker,
onClick: () => openEdit(row.profile, { onClick: () => openEdit(row.profile, {
kind: "server", kind: "server",
protocol: row.protocol, protocol: row.protocol,
@@ -572,20 +605,22 @@ export function MailProfileScopeManager({
label: `Deactivate ${row.server.name}`, label: `Deactivate ${row.server.name}`,
icon: <Trash2 size={16} />, icon: <Trash2 size={16} />,
variant: "danger", variant: "danger",
applicable: row.server.is_active, disabled: Boolean(serverDeactivationBlocker),
disabled: !canWriteProfiles || busy, disabledReason: serverDeactivationBlocker,
onClick: () => setPendingHierarchyRemoval({ kind: "server", profile: row.profile, server: row.server }) onClick: () => setPendingHierarchyRemoval({ kind: "server", profile: row.profile, server: row.server })
} }
]} />; ]} />;
} }
if (row.kind === "credential") { if (row.kind === "credential") {
const credentialMutationBlocker = mailCredentialMutationDisabledReason(canWriteProfiles, canManageCredentials, busy);
return <TableActionGroup actions={[ return <TableActionGroup actions={[
{ {
id: "edit-credentials", id: "edit-credentials",
label: `Edit ${row.credential.name}`, label: `Edit ${row.credential.name}`,
icon: <Pencil size={16} />, icon: <Pencil size={16} />,
disabled: !canWriteProfiles || !canManageCredentials || busy, disabled: Boolean(credentialMutationBlocker),
disabledReason: credentialMutationBlocker,
onClick: () => openEdit(row.profile, { onClick: () => openEdit(row.profile, {
kind: "credentials", kind: "credentials",
protocol: row.protocol, protocol: row.protocol,
@@ -598,7 +633,8 @@ export function MailProfileScopeManager({
label: `Unlink ${row.credential.name}`, label: `Unlink ${row.credential.name}`,
icon: <Unlink size={16} />, icon: <Unlink size={16} />,
variant: "danger", variant: "danger",
disabled: !canWriteProfiles || !canManageCredentials || busy, disabled: Boolean(credentialMutationBlocker),
disabledReason: credentialMutationBlocker,
onClick: () => setPendingHierarchyRemoval({ onClick: () => setPendingHierarchyRemoval({
kind: "credential", kind: "credential",
profile: row.profile, profile: row.profile,
@@ -612,17 +648,47 @@ export function MailProfileScopeManager({
const deactivationDeletesCredentials = Boolean( const deactivationDeletesCredentials = Boolean(
row.profile.smtp_password_configured || row.profile.imap_password_configured row.profile.smtp_password_configured || row.profile.imap_password_configured
); );
const deactivationBlocker = profileMutationBlocker
|| (!row.profile.is_active ? "This Mail profile is already inactive." : "")
|| (deactivationDeletesCredentials && !canManageCredentials
? "Credential-management permission is required because deactivation will scrub saved secrets."
: "");
return <TableActionGroup actions={[ return <TableActionGroup actions={[
{ id: "edit", label: i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name }), icon: <Pencil size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile) }, { id: "edit", label: i18nMessage("i18n:govoplan-mail.edit_value.fad75899", { value0: row.profile.name }), icon: <Pencil size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openEdit(row.profile) },
{ id: "add-smtp", label: "Add SMTP server", icon: <Plus size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openCreateServer(row.profile, "smtp") }, { id: "add-smtp", label: "Add SMTP server", icon: <Plus size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openCreateServer(row.profile, "smtp") },
{ id: "add-imap", label: "Add IMAP server", icon: <Plus size={16} />, disabled: !canWriteProfiles || busy, onClick: () => openCreateServer(row.profile, "imap") }, { id: "add-imap", label: "Add IMAP server", icon: <Plus size={16} />, disabled: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openCreateServer(row.profile, "imap") },
{ id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: <Trash2 size={16} />, variant: "danger", applicable: row.profile.is_active, disabled: !canWriteProfiles || busy || (deactivationDeletesCredentials && !canManageCredentials), onClick: () => setDeactivating(row.profile) } { id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: <Trash2 size={16} />, variant: "danger", disabled: Boolean(deactivationBlocker), disabledReason: deactivationBlocker, onClick: () => setDeactivating(row.profile) }
]} />; ]} />;
} }
return ( return (
<div className="mail-profile-manager"> <div className="mail-profile-manager">
{!canWriteProfiles &&
<ActionBlockerHint
tone="info"
reason={{
summary: "You can review Mail profiles here, but cannot change them from this scope.",
details: "Creating profiles, changing servers, and linking credentials require Mail profile administration authority.",
requiredAction: "Ask an administrator to grant Mail profile administration permission or make the change at an authorized scope.",
actor: "System, tenant, group, or user Mail administrator",
target: "Administration or Settings > Mail profiles"
}}
documentation={MAIL_PROFILE_DOCUMENTATION} />
}
{targetSelectionRequired && !hasSelectableTarget &&
<ActionBlockerHint
reason={{
summary: "No target is available for this Mail profile scope.",
details: "User, group, and campaign profile settings need a concrete target before profiles and effective policy can be loaded.",
requiredAction: "Create or select a target first.",
actor: "The administrator responsible for this scope",
target: targetPluralLabel(scopeType, targetLabel)
}}
documentation={MAIL_PROFILE_DOCUMENTATION} />
}
{targetSelectionRequired && {targetSelectionRequired &&
<Card title={i18nMessage("i18n:govoplan-mail.value_scope", { value0: targetLabel })}> <Card title={i18nMessage("i18n:govoplan-mail.value_scope", { value0: targetLabel })}>
<div className="settings-target-row"> <div className="settings-target-row">
@@ -642,8 +708,9 @@ export function MailProfileScopeManager({
title={profileTitle} title={profileTitle}
actions={ actions={
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => void loadProfiles()} disabled={loading}>{loading ? "i18n:govoplan-mail.loading.33ce4174" : "i18n:govoplan-mail.reload.cce71553"}</Button> <DocumentationHelpLink reference={MAIL_PROFILE_DOCUMENTATION} />
<Button variant="primary" onClick={openCreate} disabled={!canWriteProfiles || busy}><Plus size={16} />i18n:govoplan-mail.new_profile.ca36da25</Button> <Button onClick={() => void loadProfiles()} disabled={Boolean(profileReloadBlocker)} disabledReason={profileReloadBlocker}>{loading ? "i18n:govoplan-mail.loading.33ce4174" : "i18n:govoplan-mail.reload.cce71553"}</Button>
<Button variant="primary" onClick={openCreate} disabled={Boolean(profileMutationBlocker)} disabledReason={profileMutationBlocker}><Plus size={16} />i18n:govoplan-mail.new_profile.ca36da25</Button>
</div> </div>
}> }>
@@ -693,7 +760,8 @@ export function MailProfileScopeManager({
<Button <Button
variant="primary" variant="primary"
onClick={() => moveCreateStage(1)} onClick={() => moveCreateStage(1)}
disabled={!canWriteProfiles || busy || !createStageCanContinue} disabled={Boolean(createStageBlocker)}
disabledReason={createStageBlocker}
> >
Next Next
<ArrowRight size={16} /> <ArrowRight size={16} />
@@ -703,13 +771,14 @@ export function MailProfileScopeManager({
<Button <Button
variant="primary" variant="primary"
onClick={() => void saveProfile()} onClick={() => void saveProfile()}
disabled={!canWriteProfiles || busy || !scopeReady || !profileEditorCanSave(draft, editing, editingTarget, reuseCredentialId)} disabled={Boolean(editorSaveBlocker)}
disabledReason={editorSaveBlocker}
> >
{busy ? "i18n:govoplan-mail.saving.56a2285c" : "Create profile"} {busy ? "i18n:govoplan-mail.saving.56a2285c" : "Create profile"}
</Button> </Button>
)} )}
</> </>
: <><Button onClick={closeProfileEditor} disabled={busy}>i18n:govoplan-mail.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !scopeReady || !profileEditorCanSave(draft, editing, editingTarget, reuseCredentialId)}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_profile.f597c0e8"}</Button></>}> : <><Button onClick={closeProfileEditor} disabled={busy} disabledReason={busy ? "Wait for the current Mail profile change to finish." : undefined}>i18n:govoplan-mail.cancel.77dfd213</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={Boolean(editorSaveBlocker)} disabledReason={editorSaveBlocker}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_profile.f597c0e8"}</Button></>}>
{editing === "new" && ( {editing === "new" && (
<StageRail <StageRail
@@ -869,6 +938,7 @@ export function MailProfilePolicyEditor({
const displayPolicy = useMemo(() => isSystem ? concreteSystemPolicy(policy) : policy, [isSystem, policy]); const displayPolicy = useMemo(() => isSystem ? concreteSystemPolicy(policy) : policy, [isSystem, policy]);
const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []); const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []);
const disabled = locked || busy || loading || !canWrite || !scopeReady; const disabled = locked || busy || loading || !canWrite || !scopeReady;
const policySaveBlocker = mailPolicyDisabledReason(locked, canWrite, scopeReady, loading, busy, policyDirty);
const parentAllowedProfileIds = parentPolicy?.allowed_profile_ids?.length ? new Set(parentPolicy.allowed_profile_ids) : null; const parentAllowedProfileIds = parentPolicy?.allowed_profile_ids?.length ? new Set(parentPolicy.allowed_profile_ids) : null;
const parentBlocksUserProfiles = parentPolicy?.allow_user_profiles === false; const parentBlocksUserProfiles = parentPolicy?.allow_user_profiles === false;
const parentBlocksGroupProfiles = parentPolicy?.allow_group_profiles === false; const parentBlocksGroupProfiles = parentPolicy?.allow_group_profiles === false;
@@ -931,13 +1001,20 @@ export function MailProfilePolicyEditor({
title={title} title={title}
actions={ actions={
<div className="button-row compact-actions"> <div className="button-row compact-actions">
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "i18n:govoplan-mail.loading.33ce4174" : "i18n:govoplan-mail.reload.cce71553"}</Button> <DocumentationHelpLink reference={MAIL_PROFILE_DOCUMENTATION} />
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_policy.77d67ce3"}</Button> <Button onClick={() => void loadPolicy()} disabled={loading || busy || !scopeReady} disabledReason={loading ? "Mail policy is already loading." : busy ? "Wait for the current policy change to finish." : !scopeReady ? "Select a policy target before reloading." : undefined}>{loading ? "i18n:govoplan-mail.loading.33ce4174" : "i18n:govoplan-mail.reload.cce71553"}</Button>
<Button variant="primary" onClick={() => void savePolicy()} disabled={Boolean(policySaveBlocker)} disabledReason={policySaveBlocker}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_policy.77d67ce3"}</Button>
</div> </div>
}> }>
<LoadingFrame loading={loading} label="i18n:govoplan-mail.loading_mail_profile_policy.b746a2e8"> <LoadingFrame loading={loading} label="i18n:govoplan-mail.loading_mail_profile_policy.b746a2e8">
<div className="mail-policy-editor"> <div className="mail-policy-editor">
{(locked || !canWrite || !scopeReady) &&
<ActionBlockerHint
tone={locked ? "warning" : "info"}
reason={mailPolicyBlockerReason(locked, canWrite, scopeReady)}
documentation={MAIL_PROFILE_DOCUMENTATION} />
}
{description && <p className="muted small-note mail-policy-description">{description}</p>} {description && <p className="muted small-note mail-policy-description">{description}</p>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>} {success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
@@ -1170,6 +1247,11 @@ function ProfileForm({
smtpHost: draft.smtpHost, smtpHost: draft.smtpHost,
imapHost: draft.imapHost imapHost: draft.imapHost
}), [draft.imapHost, draft.smtpHost, effectivePolicy]); }), [draft.imapHost, draft.smtpHost, effectivePolicy]);
const profileFormMutationBlocker = mailProfileMutationDisabledReason(canWrite, busy);
const smtpTestBlocker = profileFormMutationBlocker
|| (!draft.smtpHost.trim() ? "Enter an SMTP server hostname before testing the connection." : "");
const imapTestBlocker = profileFormMutationBlocker
|| (!draftHasImap ? "Enter an IMAP server hostname before testing the connection." : "");
useEffect(() => { useEffect(() => {
setSmtpTestResult(null); setSmtpTestResult(null);
@@ -1245,9 +1327,24 @@ function ProfileForm({
return ( return (
<div className="mail-profile-form"> <div className="mail-profile-form">
{!canManageCredentials && (
editTarget.kind === "credentials"
|| (editing === "new" && (createStage === "smtp_credentials" || createStage === "imap_credentials"))
) &&
<ActionBlockerHint
tone="info"
reason={{
summary: "Credential fields are read-only for your current role.",
details: "Profile metadata and server configuration are separate from encrypted credential authority.",
requiredAction: "Ask a Mail credential administrator to create, replace, or link the required credential.",
actor: "Mail credential administrator",
target: "The credential row beneath the relevant SMTP or IMAP server"
}}
documentation={MAIL_PROFILE_DOCUMENTATION} />
}
{showProfileFields && {showProfileFields &&
<div className="admin-form-grid two-columns"> <div className="admin-form-grid two-columns">
<FormField label="i18n:govoplan-mail.name.709a2322"><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField> <FormField label="i18n:govoplan-mail.name.709a2322" documentation={MAIL_PROFILE_DOCUMENTATION}><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-mail.slug.094da9b9"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" /></FormField> <FormField label="i18n:govoplan-mail.slug.094da9b9"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" /></FormField>
<FormField label="i18n:govoplan-mail.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-mail.active.a733b809</option><option value="inactive">i18n:govoplan-mail.inactive.09af574c</option></select></FormField> <FormField label="i18n:govoplan-mail.status.bae7d5be"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">i18n:govoplan-mail.active.a733b809</option><option value="inactive">i18n:govoplan-mail.inactive.09af574c</option></select></FormField>
<FormField label="i18n:govoplan-mail.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField> <FormField label="i18n:govoplan-mail.description.55f8ebc8"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
@@ -1267,7 +1364,7 @@ function ProfileForm({
{editTarget.kind === "server" && {editTarget.kind === "server" &&
<div className="admin-form-grid two-columns"> <div className="admin-form-grid two-columns">
<FormField label="Server name"> <FormField label="Server name" documentation={MAIL_PROFILE_DOCUMENTATION}>
<input value={draft.serverName} disabled={disabled} onChange={(event) => setDraft({ ...draft, serverName: event.target.value })} /> <input value={draft.serverName} disabled={disabled} onChange={(event) => setDraft({ ...draft, serverName: event.target.value })} />
</FormField> </FormField>
<FormField label="Protocol"> <FormField label="Protocol">
@@ -1296,7 +1393,7 @@ function ProfileForm({
{editTarget.kind === "credentials" && {editTarget.kind === "credentials" &&
<div className="admin-form-grid two-columns"> <div className="admin-form-grid two-columns">
{creatingCredential && {creatingCredential &&
<FormField label="Reusable credential"> <FormField label="Reusable credential" documentation={MAIL_PROFILE_WORKFLOW_DOCUMENTATION}>
<select value={reuseCredentialId} disabled={credentialDisabled} onChange={(event) => setReuseCredentialId(event.target.value)}> <select value={reuseCredentialId} disabled={credentialDisabled} onChange={(event) => setReuseCredentialId(event.target.value)}>
<option value="">Create a new credential</option> <option value="">Create a new credential</option>
{availableCredentials.map((credential) => {availableCredentials.map((credential) =>
@@ -1310,7 +1407,7 @@ function ProfileForm({
{creatingCredential && <div />} {creatingCredential && <div />}
{!reuseCredentialId && {!reuseCredentialId &&
<> <>
<FormField label="Credential name"> <FormField label="Credential name" documentation={MAIL_PROFILE_DOCUMENTATION}>
<input value={draft.credentialName} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, credentialName: event.target.value })} /> <input value={draft.credentialName} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, credentialName: event.target.value })} />
</FormField> </FormField>
<FormField label="Description"> <FormField label="Description">
@@ -1384,6 +1481,8 @@ function ProfileForm({
onImapCredentialsChange={patchImapCredentials} onImapCredentialsChange={patchImapCredentials}
smtpDisabled={disabled} smtpDisabled={disabled}
smtpCredentialDisabled={credentialDisabled} smtpCredentialDisabled={credentialDisabled}
smtpActionDisabled={Boolean(smtpTestBlocker)}
smtpActionDisabledReason={smtpTestBlocker}
smtpPasswordSaved={Boolean(selectedCredential?.secret_configured || existingProfile?.smtp_password_configured)} smtpPasswordSaved={Boolean(selectedCredential?.secret_configured || existingProfile?.smtp_password_configured)}
imapServerDisabled={disabled} imapServerDisabled={disabled}
imapCredentialDisabled={ imapCredentialDisabled={
@@ -1395,7 +1494,8 @@ function ProfileForm({
) )
} }
imapPasswordSaved={Boolean(selectedCredential?.secret_configured || existingProfile?.imap_password_configured)} imapPasswordSaved={Boolean(selectedCredential?.secret_configured || existingProfile?.imap_password_configured)}
imapActionDisabled={disabled || !draftHasImap} imapActionDisabled={Boolean(imapTestBlocker)}
imapActionDisabledReason={imapTestBlocker}
smtpTestLabel={useSavedSmtpTest ? "i18n:govoplan-mail.test_saved_smtp.008d8054" : "i18n:govoplan-mail.test_smtp.e5697981"} smtpTestLabel={useSavedSmtpTest ? "i18n:govoplan-mail.test_saved_smtp.008d8054" : "i18n:govoplan-mail.test_smtp.e5697981"}
imapTestLabel={useSavedImapTest ? "i18n:govoplan-mail.test_saved_imap.923dbe4a" : "i18n:govoplan-mail.test_imap.ef1bd79c"} imapTestLabel={useSavedImapTest ? "i18n:govoplan-mail.test_saved_imap.923dbe4a" : "i18n:govoplan-mail.test_imap.ef1bd79c"}
busyAction={mailActionState} busyAction={mailActionState}
@@ -1680,6 +1780,90 @@ function profileEditorCanSave(
return false; return false;
} }
function mailProfileMutationDisabledReason(canWrite: boolean, busy: boolean): string {
if (busy) return "Wait for the current Mail profile change to finish.";
if (!canWrite) return "Mail profile administration permission is required for this action.";
return "";
}
function mailCredentialMutationDisabledReason(canWrite: boolean, canManageCredentials: boolean, busy: boolean): string {
return mailProfileMutationDisabledReason(canWrite, busy)
|| (!canManageCredentials ? "Mail credential-management permission is required for this action." : "");
}
function mailProfileCreateStageDisabledReason(
canWrite: boolean,
busy: boolean,
stage: MailProfileCreateStage,
canContinue: boolean
): string {
const mutationBlocker = mailProfileMutationDisabledReason(canWrite, busy);
if (mutationBlocker) return mutationBlocker;
if (canContinue) return "";
if (stage === "profile") return "Enter a profile name before continuing.";
if (stage === "smtp_server") return "Enter an SMTP server hostname before continuing.";
if (stage === "imap_credentials") return "Configure an IMAP server before adding IMAP credentials.";
return "Complete the required fields before continuing.";
}
function mailProfileEditorDisabledReason(
canWrite: boolean,
busy: boolean,
scopeReady: boolean,
canSave: boolean
): string {
const mutationBlocker = mailProfileMutationDisabledReason(canWrite, busy);
if (mutationBlocker) return mutationBlocker;
if (!scopeReady) return "Select a target scope before saving.";
if (!canSave) return "Complete the required profile, server, or credential fields before saving.";
return "";
}
function mailPolicyDisabledReason(
locked: boolean,
canWrite: boolean,
scopeReady: boolean,
loading: boolean,
busy: boolean,
dirty: boolean
): string {
if (locked) return "This policy is locked by the owning workflow or a higher-scope decision.";
if (!canWrite) return "Mail policy administration permission is required to save changes.";
if (!scopeReady) return "Select a policy target before saving.";
if (loading) return "Wait until the effective Mail policy has loaded.";
if (busy) return "Wait for the current policy change to finish.";
if (!dirty) return "There are no unsaved Mail policy changes.";
return "";
}
function mailPolicyBlockerReason(locked: boolean, canWrite: boolean, scopeReady: boolean) {
if (locked) {
return {
summary: "This Mail policy is locked in the current context.",
details: "The effective values remain visible, but this workflow or a higher-scope decision owns the editable policy.",
requiredAction: "Change the owning policy or leave the governed workflow before editing.",
actor: "The administrator or workflow owner responsible for the source policy",
target: "The source shown in the effective policy path"
};
}
if (!scopeReady) {
return {
summary: "Select a target before editing Mail policy.",
details: "User, group, and campaign policy must be resolved against one concrete target.",
requiredAction: "Choose the target in the scope selector.",
actor: "Mail policy administrator",
target: "The target selector above"
};
}
return {
summary: "You can review effective Mail policy here, but cannot change it.",
details: "Policy changes require Mail policy administration authority at this scope.",
requiredAction: "Ask an authorized administrator to apply the change.",
actor: "System or tenant policy administrator",
target: "Administration > Mail profiles and policy"
};
}
function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload { function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
return mailSmtpSettingsPayload<MailSecurity>( return mailSmtpSettingsPayload<MailSecurity>(
{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }, { host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout },
+37 -3
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react"; import { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import { import {
ActionBlockerHint,
Button, Button,
DataGridPaginationBar, DataGridPaginationBar,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
ExplorerTree, ExplorerTree,
IconButton, IconButton,
@@ -27,6 +29,11 @@ import {
"../../api/mail"; "../../api/mail";
import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders"; import { buildMailboxFolderTree, findFolderNodeId, folderAncestorIds, type MailFolderNode } from "./mailboxFolders";
const MAILBOX_DOCUMENTATION = {
topicId: "mail.workflow.read-mailbox",
documentationType: "user"
} as const;
export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) { export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const navigate = useGuardedNavigate(); const navigate = useGuardedNavigate();
const [profiles, setProfiles] = useState<MailServerProfile[]>([]); const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
@@ -73,6 +80,19 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d"); const messageEmptyText = messageError || (!selectedProfileId ? "i18n:govoplan-mail.select_an_imap_profile.5445648c" : !foldersReady || loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : messages.length > 0 && filteredMessages.length === 0 ? "i18n:govoplan-mail.no_messages_match_the_current_filter_on_this_pag.9dda6916" : "i18n:govoplan-mail.no_messages_in_this_folder.5c7fa25d");
const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342"); const previewEmptyText = detailError || (loadingMessage ? "i18n:govoplan-mail.loading_message.815c2094" : "i18n:govoplan-mail.select_a_message_to_inspect_its_content.5f3d1342");
const loadingLabel = loadingProfiles ? "i18n:govoplan-mail.loading_mail_profiles.87de3560" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : "i18n:govoplan-mail.loading_message.815c2094"; const loadingLabel = loadingProfiles ? "i18n:govoplan-mail.loading_mail_profiles.87de3560" : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : loadingMessages ? "i18n:govoplan-mail.loading_messages.77b62232" : "i18n:govoplan-mail.loading_message.815c2094";
const profileReloadBlocker = loadingProfiles ? "Mail profiles are already loading." : "";
const folderReloadBlocker = !selectedProfileId
? "Select an IMAP-enabled Mail profile before refreshing folders."
: loadingFolders || loadingMessages
? "Wait for the current mailbox refresh to finish."
: "";
const messageReloadBlocker = !selectedProfileId
? "Select an IMAP-enabled Mail profile before refreshing messages."
: !selectedFolder || !foldersReady
? "Select a loaded mailbox folder before refreshing messages."
: loadingMessages
? "Messages are already loading."
: "";
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]); useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]); useEffect(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
@@ -397,26 +417,40 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
</label> </label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span> <span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span>
<div className="mailbox-toolbar-actions"> <div className="mailbox-toolbar-actions">
<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) && {hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
<Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing"> <Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing">
<Activity size={16} aria-hidden="true" /> <Activity size={16} aria-hidden="true" />
Bounce status Bounce status
</Button>} </Button>}
<Button onClick={() => void loadProfiles()} disabled={loadingProfiles} title="i18n:govoplan-mail.reload_imap_profiles.b04c11c8"> <Button onClick={() => void loadProfiles()} disabled={Boolean(profileReloadBlocker)} disabledReason={profileReloadBlocker} title="i18n:govoplan-mail.reload_imap_profiles.b04c11c8">
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.profiles.0c2a9300 i18n:govoplan-mail.profiles.0c2a9300
</Button> </Button>
<Button onClick={() => void loadMailboxBootstrap(selectedProfileId, true)} disabled={!selectedProfileId || loadingFolders || loadingMessages} title="i18n:govoplan-mail.refresh_mailbox_folders.d9af9963"> <Button onClick={() => void loadMailboxBootstrap(selectedProfileId, true)} disabled={Boolean(folderReloadBlocker)} disabledReason={folderReloadBlocker} title="i18n:govoplan-mail.refresh_mailbox_folders.d9af9963">
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.folders.19adc47b i18n:govoplan-mail.folders.19adc47b
</Button> </Button>
<Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize, true)} disabled={!selectedProfileId || !selectedFolder || !foldersReady || loadingMessages} title="i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c"> <Button onClick={() => void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize, true)} disabled={Boolean(messageReloadBlocker)} disabledReason={messageReloadBlocker} title="i18n:govoplan-mail.refresh_messages_in_the_current_folder.b6546a2c">
<RefreshCw size={16} aria-hidden="true" /> <RefreshCw size={16} aria-hidden="true" />
i18n:govoplan-mail.messages.f1702b46 i18n:govoplan-mail.messages.f1702b46
</Button> </Button>
</div> </div>
</div> </div>
{noImapProfiles &&
<ActionBlockerHint
className="mailbox-profile-blocker"
reason={{
summary: "No IMAP-enabled Mail profile is available.",
details: "The mailbox workspace is read-only and needs an active profile with an authorized IMAP server and credential.",
requiredAction: "Ask a Mail administrator to configure and authorize an IMAP-enabled profile.",
actor: "Mail profile administrator",
target: "Settings or Administration > Mail profiles"
}}
documentation={MAILBOX_DOCUMENTATION} />
}
<nav className="file-breadcrumbs" aria-label="i18n:govoplan-mail.current_mailbox_folder.55e2aea5"> <nav className="file-breadcrumbs" aria-label="i18n:govoplan-mail.current_mailbox_folder.55e2aea5">
<span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "i18n:govoplan-mail.mail.92379cbb"}</span> <span className="file-breadcrumb mailbox-breadcrumb-static"><Home size={15} aria-hidden="true" /> {selectedProfile?.name || "i18n:govoplan-mail.mail.92379cbb"}</span>
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span> <span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolder}</span></span>
+4
View File
@@ -506,6 +506,10 @@
white-space: nowrap; white-space: nowrap;
} }
.mailbox-profile-blocker {
margin: 12px;
}
.mailbox-breadcrumb-static { .mailbox-breadcrumb-static {
cursor: default; cursor: default;
} }