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
+58 -15
View File
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { ArrowLeft, Plus, RefreshCw, RotateCw, Trash2 } from "lucide-react";
import {
ActionBlockerHint,
Button,
Card,
ConfirmDialog,
DataGrid,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
LoadingFrame,
PageScrollViewport,
@@ -31,6 +34,11 @@ import {
type MailServerProfile
} from "../../api/mail";
const MAIL_BOUNCE_DOCUMENTATION = {
topicId: "mail.bounce-processing",
documentationType: "admin"
} as const;
export default function MailBouncePage({ settings }: { settings: ApiSettings }) {
const navigate = useGuardedNavigate();
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])),
[profiles]
);
const imapProfiles = useMemo(
() => profiles.filter((profile) => profile.is_active && profile.imap),
[profiles]
);
const pageMutationBlocker = loading
? "Bounce evidence is already loading."
: busy
? "Wait for the current bounce-processing action to finish."
: "";
const addWatcherBlocker = pageMutationBlocker
|| (imapProfiles.length === 0 ? "Configure an active IMAP-enabled Mail profile before adding a watcher." : "");
const saveWatcherBlocker = busy
? "Wait for the current bounce-processing action to finish."
: !profileId
? "Select an active IMAP-enabled Mail profile."
: !folder.trim()
? "Enter the mailbox folder that contains delivery-status messages."
: "";
async function load() {
setLoading(true);
@@ -153,8 +179,8 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
width: 92,
sticky: "end",
render: (source) => <TableActionGroup actions={[
{ id: "scan", label: "Scan now", icon: <RotateCw aria-hidden="true" />, disabled: Boolean(busy), onClick: () => void scan(source) },
{ id: "delete", label: "Remove watcher", icon: <Trash2 aria-hidden="true" />, variant: "danger", disabled: Boolean(busy), onClick: () => setDeleteSource(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), 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 className="button-row compact-actions">
<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>
<Button variant="primary" onClick={() => setAddOpen(true)} disabled={loading}><Plus size={16} aria-hidden="true" /> Add watcher</Button>
<DocumentationHelpLink reference={MAIL_BOUNCE_DOCUMENTATION} />
<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>
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
@@ -192,25 +219,41 @@ export default function MailBouncePage({ settings }: { settings: ApiSettings })
</LoadingFrame>
</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">
<FormField label="Mail profile">
<select value={profileId} onChange={(event) => setProfileId(event.target.value)}>
{imapProfiles.length === 0 &&
<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>
{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>
</FormField>
<FormField label="Bounce folder">
<input value={folder} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
<FormField label="Bounce folder" documentation={MAIL_BOUNCE_DOCUMENTATION}>
<input value={folder} disabled={Boolean(busy)} onChange={(event) => setFolder(event.target.value)} placeholder="INBOX" />
</FormField>
<ToggleSwitch checked={active} onChange={setActive} label="Watch automatically" />
<ToggleSwitch checked={active} disabled={Boolean(busy)} onChange={setActive} label="Watch automatically" />
</div>
</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></>}>
<p>The watcher will stop scanning this folder. Existing bounce observations and delivery evidence remain available.</p>
</Dialog>
<ConfirmDialog
open={deleteSource !== null}
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>
);
}
+207 -23
View File
@@ -1,5 +1,5 @@
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 type { ApiSettings } from "../../types";
import {
@@ -147,6 +147,16 @@ type PendingHierarchyRemoval =
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> = {
smtp_hosts: "i18n:govoplan-mail.smtp_hostnames.36eb51d8",
imap_hosts: "i18n:govoplan-mail.imap_hostnames.ac9c1d78",
@@ -214,6 +224,24 @@ export function MailProfileScopeManager({
createStage,
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({
dirty: profileDirty,
@@ -548,19 +576,24 @@ export function MailProfileScopeManager({
function renderMailProfileActions(row: MailProfileTreeRow) {
if (row.kind === "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={[
{
id: "add-credential",
label: "Add or link credential",
icon: <Link2 size={16} />,
disabled: !canWriteProfiles || !canManageCredentials || busy,
disabled: Boolean(credentialMutationBlocker),
disabledReason: credentialMutationBlocker,
onClick: () => openCreateCredential(row.profile, row.server)
},
{
id: "edit-server",
label,
icon: <Pencil size={16} />,
disabled: !canWriteProfiles || busy,
disabled: Boolean(serverMutationBlocker),
disabledReason: serverMutationBlocker,
onClick: () => openEdit(row.profile, {
kind: "server",
protocol: row.protocol,
@@ -572,20 +605,22 @@ export function MailProfileScopeManager({
label: `Deactivate ${row.server.name}`,
icon: <Trash2 size={16} />,
variant: "danger",
applicable: row.server.is_active,
disabled: !canWriteProfiles || busy,
disabled: Boolean(serverDeactivationBlocker),
disabledReason: serverDeactivationBlocker,
onClick: () => setPendingHierarchyRemoval({ kind: "server", profile: row.profile, server: row.server })
}
]} />;
}
if (row.kind === "credential") {
const credentialMutationBlocker = mailCredentialMutationDisabledReason(canWriteProfiles, canManageCredentials, busy);
return <TableActionGroup actions={[
{
id: "edit-credentials",
label: `Edit ${row.credential.name}`,
icon: <Pencil size={16} />,
disabled: !canWriteProfiles || !canManageCredentials || busy,
disabled: Boolean(credentialMutationBlocker),
disabledReason: credentialMutationBlocker,
onClick: () => openEdit(row.profile, {
kind: "credentials",
protocol: row.protocol,
@@ -598,7 +633,8 @@ export function MailProfileScopeManager({
label: `Unlink ${row.credential.name}`,
icon: <Unlink size={16} />,
variant: "danger",
disabled: !canWriteProfiles || !canManageCredentials || busy,
disabled: Boolean(credentialMutationBlocker),
disabledReason: credentialMutationBlocker,
onClick: () => setPendingHierarchyRemoval({
kind: "credential",
profile: row.profile,
@@ -612,17 +648,47 @@ export function MailProfileScopeManager({
const deactivationDeletesCredentials = Boolean(
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={[
{ 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: "add-smtp", label: "Add SMTP server", icon: <Plus size={16} />, disabled: !canWriteProfiles || busy, 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: "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: "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: Boolean(profileMutationBlocker), disabledReason: profileMutationBlocker, onClick: () => openCreateServer(row.profile, "smtp") },
{ 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", disabled: Boolean(deactivationBlocker), disabledReason: deactivationBlocker, onClick: () => setDeactivating(row.profile) }
]} />;
}
return (
<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 &&
<Card title={i18nMessage("i18n:govoplan-mail.value_scope", { value0: targetLabel })}>
<div className="settings-target-row">
@@ -642,8 +708,9 @@ export function MailProfileScopeManager({
title={profileTitle}
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>
<Button variant="primary" onClick={openCreate} disabled={!canWriteProfiles || busy}><Plus size={16} />i18n:govoplan-mail.new_profile.ca36da25</Button>
<DocumentationHelpLink reference={MAIL_PROFILE_DOCUMENTATION} />
<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>
}>
@@ -693,7 +760,8 @@ export function MailProfileScopeManager({
<Button
variant="primary"
onClick={() => moveCreateStage(1)}
disabled={!canWriteProfiles || busy || !createStageCanContinue}
disabled={Boolean(createStageBlocker)}
disabledReason={createStageBlocker}
>
Next
<ArrowRight size={16} />
@@ -703,13 +771,14 @@ export function MailProfileScopeManager({
<Button
variant="primary"
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"}
</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" && (
<StageRail
@@ -869,6 +938,7 @@ export function MailProfilePolicyEditor({
const displayPolicy = useMemo(() => isSystem ? concreteSystemPolicy(policy) : policy, [isSystem, policy]);
const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []);
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 parentBlocksUserProfiles = parentPolicy?.allow_user_profiles === false;
const parentBlocksGroupProfiles = parentPolicy?.allow_group_profiles === false;
@@ -931,13 +1001,20 @@ export function MailProfilePolicyEditor({
title={title}
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>
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "i18n:govoplan-mail.saving.56a2285c" : "i18n:govoplan-mail.save_policy.77d67ce3"}</Button>
<DocumentationHelpLink reference={MAIL_PROFILE_DOCUMENTATION} />
<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>
}>
<LoadingFrame loading={loading} label="i18n:govoplan-mail.loading_mail_profile_policy.b746a2e8">
<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>}
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
@@ -1170,6 +1247,11 @@ function ProfileForm({
smtpHost: draft.smtpHost,
imapHost: draft.imapHost
}), [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(() => {
setSmtpTestResult(null);
@@ -1245,9 +1327,24 @@ function ProfileForm({
return (
<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 &&
<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.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>
@@ -1267,7 +1364,7 @@ function ProfileForm({
{editTarget.kind === "server" &&
<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 })} />
</FormField>
<FormField label="Protocol">
@@ -1296,7 +1393,7 @@ function ProfileForm({
{editTarget.kind === "credentials" &&
<div className="admin-form-grid two-columns">
{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)}>
<option value="">Create a new credential</option>
{availableCredentials.map((credential) =>
@@ -1310,7 +1407,7 @@ function ProfileForm({
{creatingCredential && <div />}
{!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 })} />
</FormField>
<FormField label="Description">
@@ -1384,6 +1481,8 @@ function ProfileForm({
onImapCredentialsChange={patchImapCredentials}
smtpDisabled={disabled}
smtpCredentialDisabled={credentialDisabled}
smtpActionDisabled={Boolean(smtpTestBlocker)}
smtpActionDisabledReason={smtpTestBlocker}
smtpPasswordSaved={Boolean(selectedCredential?.secret_configured || existingProfile?.smtp_password_configured)}
imapServerDisabled={disabled}
imapCredentialDisabled={
@@ -1395,7 +1494,8 @@ function ProfileForm({
)
}
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"}
imapTestLabel={useSavedImapTest ? "i18n:govoplan-mail.test_saved_imap.923dbe4a" : "i18n:govoplan-mail.test_imap.ef1bd79c"}
busyAction={mailActionState}
@@ -1680,6 +1780,90 @@ function profileEditorCanSave(
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 {
return mailSmtpSettingsPayload<MailSecurity>(
{ 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 { Activity, ChevronRight, Home, Mail, Paperclip, RefreshCw, Search, X } from "lucide-react";
import {
ActionBlockerHint,
Button,
DataGridPaginationBar,
DocumentationHelpLink,
DismissibleAlert,
ExplorerTree,
IconButton,
@@ -27,6 +29,11 @@ import {
"../../api/mail";
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 }) {
const navigate = useGuardedNavigate();
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 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 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(() => {selectedMessageKeyRef.current = selectedMessageKeyState;}, [selectedMessageKeyState]);
@@ -397,26 +417,40 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
</label>
<span className="mailbox-toolbar-meta">{selectedProfile?.imap ? transportLabel(selectedProfile) : "i18n:govoplan-mail.no_imap_profile_selected.e7d1516f"}</span>
<div className="mailbox-toolbar-actions">
<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />
{hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) &&
<Button onClick={() => navigate("/mail/bounces")} title="Open bounce processing">
<Activity size={16} aria-hidden="true" />
Bounce status
</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" />
i18n:govoplan-mail.profiles.0c2a9300
</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" />
i18n:govoplan-mail.folders.19adc47b
</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" />
i18n:govoplan-mail.messages.f1702b46
</Button>
</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">
<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>
+4
View File
@@ -506,6 +506,10 @@
white-space: nowrap;
}
.mailbox-profile-blocker {
margin: 12px;
}
.mailbox-breadcrumb-static {
cursor: default;
}