Migrate Mail surfaces to interface patterns
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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.");
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user