Release govoplan-mail v0.1.27: stabilize credentials, folder encoding and transport progress
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-09-08 01:32:44 +02:00
parent c62c7783d6
commit 480c18c67c
27 changed files with 2875 additions and 1005 deletions
+3 -3
View File
@@ -1,17 +1,17 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.26",
"version": "0.1.27",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.26",
"version": "0.1.27",
"devDependencies": {
"typescript": "^5.7.2"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.26",
"version": "0.1.27",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,7 +14,7 @@
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
@@ -6,8 +6,9 @@ function read(relativePath) {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
}
const profiles = read("../src/features/mail/MailProfileManagement.tsx");
const profiles = `${read("../src/features/mail/MailProfileManagement.tsx")}\n${read("../src/features/mail/MailProfilePolicyEditor.tsx")}`;
const mailbox = read("../src/features/mail/MailboxPage.tsx");
const mailApi = read("../src/api/mail.ts");
const bounces = read("../src/features/mail/MailBouncePage.tsx");
const legacyImport = read("../src/features/mail/MailLegacyImportPage.tsx");
const moduleSource = read("../src/module.ts");
@@ -34,6 +35,32 @@ assert.match(mailbox, /topicId: "mail\.workflow\.read-mailbox"/);
assert.match(mailbox, /disabledReason=\{folderReloadBlocker\}/);
assert.match(mailbox, /folder_mappings\?\.inbox/);
assert.match(mailbox, /detected_folder_mappings\?\.inbox/);
const openFolderNode = mailbox.slice(mailbox.indexOf(" function openFolderNode("), mailbox.indexOf(" function toggleFolderNode("));
assert.doesNotMatch(openFolderNode, /toggleFolderNode\(/, "Folder labels select; only the folder button expands or collapses.");
assert.match(openFolderNode, /setSelectedFolderGroup\(\{ id: node\.id, label: node\.label \}\)/);
assert.match(openFolderNode, /messageDetailRequestRef\.current \+= 1/, "Selecting a grouping invalidates pending message detail.");
assert.match(mailbox, /selectedFolderGroup\?\.id \?\? findFolderNodeId/);
assert.match(mailbox, /<WorkspaceFrame[^>]*height="viewport"/);
assert.match(mailbox, /<WorkspaceActionBar\s+variant="workspace"\s+scope="workspace"[\s\S]*refreshable[\s\S]*onReload: \(\) => void reloadMailbox\(\)/);
assert.ok(mailbox.indexOf("<WorkspaceActionBar") < mailbox.indexOf('<aside className="file-tree-panel"'), "Mailbox actions remain outside all selectable panes.");
assert.equal((mailbox.match(/<WorkspaceActionBar/g) ?? []).length, 1, "One persistent page-wide action bar.");
assert.doesNotMatch(mailbox, /mailbox-toolbar|<ToolbarGroup/);
assert.match(mailbox, /const MAILBOX_READ_OPTIONS = \{ cache: "no-store" \}/);
assert.match(mailbox, /listMailServerProfiles\(settings, false, undefined, MAILBOX_READ_OPTIONS\)/);
for (const helper of ["listMailServerProfiles", "listMailboxFolders", "bootstrapMailbox", "listMailboxMessages", "getMailboxMessage"]) {
const start = mailApi.indexOf(`export async function ${helper}(`);
const end = mailApi.indexOf("\nexport async function ", start + 1);
const source = mailApi.slice(start, end < 0 ? undefined : end);
assert.match(source, /options\?: MailboxReadOptions/, `${helper} supports a scoped fresh read.`);
assert.match(source, /\}\), options\)/, `${helper} passes the read options to Core without global invalidation.`);
}
assert.match(mailbox, /<Dialog open=\{mailToolsOpen\}[\s\S]*mailbox_refresh_tools[\s\S]*refresh_mailbox_profiles[\s\S]*refresh_mailbox_folder_catalogue[\s\S]*refresh_mailbox_message_index[\s\S]*mailbox_related_tools/);
const refreshMailbox = mailbox.slice(mailbox.indexOf(" async function reloadMailbox("), mailbox.indexOf(" function selectProfile("));
assert.match(refreshMailbox, /await loadProfiles\(\)[\s\S]*if \(selectedFolderGroup\) await refreshFolderCatalogue\(result.selected\)/);
assert.match(refreshMailbox, /requestAuthority !== authorityRef.current/);
assert.match(refreshMailbox, /profileId === selectedProfileIdRef.current/);
assert.match(refreshMailbox, /if \(!preservePreview\) setSelectedMessage\(null\)/, "Failed preview refresh keeps the previously loaded message.");
assert.doesNotMatch(refreshMailbox, /sendMail|appendMail|moveMail|deleteMail|setFlags|createMail/, "Mailbox reload must remain provider-read-only.");
assert.match(mailbox, /onKeyDown=\{\(event\) => \{[\s\S]*event\.key === "Enter" \|\| event\.key === " "/);
assert.match(bounces, /DocumentationHelpLink/);
@@ -75,7 +102,8 @@ assert.doesNotMatch(`${profiles}\n${mailbox}\n${bounces}\n${legacyImport}\n${mod
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: 1280px\)[\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/);
assert.doesNotMatch(styles, /\.mailbox-toolbar/, "Core owns workspace toolbar wrapping and action ordering.");
assert.match(styles, /@media \(max-width: 760px\)[\s\S]*\.mailbox-message-row[\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("/", "\\/")));
+17 -11
View File
@@ -272,11 +272,14 @@ export async function createMailAddressContact(
});
}
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
return apiGetList<MailServerProfile, "profiles">(settings, "/api/v1/mail/profiles", "profiles", {
type MailboxReadOptions = Pick<RequestInit, "cache" | "signal">;
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string, options?: MailboxReadOptions): Promise<MailServerProfile[]> {
const response = await apiFetch<{ profiles?: MailServerProfile[] | null }>(settings, apiPath("/api/v1/mail/profiles", {
include_inactive: includeInactive ? true : undefined,
campaign_id: campaignId
});
}), options);
return response.profiles ?? [];
}
export async function fetchMailSettingsDelta(
@@ -679,12 +682,12 @@ export async function listMailProfileImapFolders(
);
}
export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false, protocol: MailMailboxProtocol = "imap"): Promise<MailImapFolderListResponse> {
export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false, protocol: MailMailboxProtocol = "imap", options?: MailboxReadOptions): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/folders`, {
include_status: includeStatus ? true : undefined,
refresh: refresh ? true : undefined,
protocol
}));
}), options);
}
export async function bootstrapMailbox(
@@ -694,7 +697,8 @@ export async function bootstrapMailbox(
limit = 50,
offset = 0,
refresh = false,
protocol: MailMailboxProtocol = "imap"
protocol: MailMailboxProtocol = "imap",
options?: MailboxReadOptions
): Promise<MailMailboxBootstrapResponse> {
return apiFetch<MailMailboxBootstrapResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/bootstrap`, {
folder,
@@ -702,7 +706,7 @@ export async function bootstrapMailbox(
offset,
refresh: refresh ? true : undefined,
protocol
}));
}), options);
}
export async function listMailboxMessages(
@@ -714,7 +718,8 @@ export async function listMailboxMessages(
cursor?: string | null,
refresh = false,
protocol: MailMailboxProtocol = "imap",
query?: string | null
query?: string | null,
options?: MailboxReadOptions
): Promise<MailMailboxMessageListResponse> {
return apiFetch<MailMailboxMessageListResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages`, {
folder,
@@ -724,7 +729,7 @@ export async function listMailboxMessages(
refresh: refresh ? true : undefined,
protocol,
q: query || undefined
}));
}), options);
}
export async function getMailboxMessage(
@@ -732,9 +737,10 @@ export async function getMailboxMessage(
profileId: string,
folder: string,
uid: string,
protocol: MailMailboxProtocol = "imap"
protocol: MailMailboxProtocol = "imap",
options?: MailboxReadOptions
): Promise<MailMailboxMessageResponse> {
return apiFetch<MailMailboxMessageResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder, protocol }));
return apiFetch<MailMailboxMessageResponse>(settings, apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/mailbox/messages/${encodeURIComponent(uid)}`, { folder, protocol }), options);
}
export async function getMailboxChanges(
@@ -1,46 +1,8 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ActionToolbar, FormGrid, ActionBlockerHint, AdminSelectionList, ConnectionTree, DocumentationHelpLink, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailJmapTransportSettings, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
import { FormGrid, ActionBlockerHint, ConnectionTree, DocumentationHelpLink, LoadingFrame, MailServerSettingsPanel, StageRail, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailImapFolderMappings, normalizeMailServerSecurity, useDeltaWatermarks, type ConnectionTreeColumn, type MailImapFolderMappings, type MailImapFolderListResponse, type MailJmapTransportSettings, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings } 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 {
bindMailServerCredential,
createMailServerCredential,
createMailServerEndpoint,
createMailServerProfile,
deactivateMailServerEndpoint,
deactivateMailServerProfile,
fetchMailSettingsDelta,
getMailProfilePolicy,
listImapFolders,
listMailProfileImapFolders,
mailProfilePatternKeys,
mailProfilePolicyLimitKeys,
listAvailableMailCredentials,
updateMailProfilePolicy,
testImapSettings,
testMailProfileImap,
testMailProfileJmap,
testMailProfileSmtp,
testSmtpSettings,
unlinkMailServerCredential,
updateMailServerCredential,
updateMailServerEndpoint,
updateMailServerProfile,
type MailCredentialEnvelope,
type MailCredentialPolicy,
type MailImapTestPayload,
type MailProfilePatternKey,
type MailProfilePatternRules,
type MailProfilePolicy,
type MailProfilePolicyLimitKey,
type MailProfileScope,
type MailSecurity,
type MailServerEndpoint,
type MailServerProfile,
type MailServerProfilePayload,
type MailServerProfileUpdatePayload,
type MailSmtpTestPayload } from
"../../api/mail";
import { bindMailServerCredential, createMailServerCredential, createMailServerEndpoint, createMailServerProfile, deactivateMailServerEndpoint, deactivateMailServerProfile, fetchMailSettingsDelta, listImapFolders, listMailProfileImapFolders, listAvailableMailCredentials, testImapSettings, testMailProfileImap, testMailProfileJmap, testMailProfileSmtp, testSmtpSettings, unlinkMailServerCredential, updateMailServerCredential, updateMailServerEndpoint, updateMailServerProfile, type MailCredentialEnvelope, type MailImapTestPayload, type MailProfilePolicy, type MailProfileScope, type MailSecurity, type MailServerEndpoint, type MailServerProfile, type MailServerProfilePayload, type MailServerProfileUpdatePayload, type MailSmtpTestPayload } from "../../api/mail";
import { validateMailPolicy } from "./mailPolicyValidation";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
@@ -48,29 +10,10 @@ import { ConfirmDialog } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { FormField, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui";
import {
ReferenceMultiSelect,
customReferenceOption,
platformModuleReferenceProvider,
staticReferenceOptionProvider,
usePlatformLanguage,
usePlatformModules,
type ReferenceOption
} from "@govoplan/core-webui";
import {
mailProfileEditTargetInitialSection,
mailProfileEditTargetPanelMode,
mailProfileEditTargetShowsProfileFields,
mailProfileEditTargetShowsSettingsPanel,
mailProfileEditTargetVisibleSections,
mailProfileCreateStageCanContinue,
mailProfileCreateStagePanel,
mailProfileCreateStages,
mailProfileCreateCredentialsPayload,
type MailProfileCreateStage,
type MailProfileEditTarget,
type MailProfileProtocol
} from "./mailProfileEditorModel";
import { ReferenceMultiSelect, customReferenceOption, platformModuleReferenceProvider, staticReferenceOptionProvider, usePlatformLanguage, usePlatformModules, type ReferenceOption } from "@govoplan/core-webui";
import { mailProfileEditTargetInitialSection, mailProfileEditTargetPanelMode, mailProfileEditTargetShowsProfileFields, mailProfileEditTargetShowsSettingsPanel, mailProfileEditTargetVisibleSections, mailProfileCreateStageCanContinue, mailProfileCreateStagePanel, mailProfileCreateStages, mailProfileCreateCredentialsPayload, type MailProfileCreateStage, type MailProfileEditTarget, type MailProfileProtocol } from "./mailProfileEditorModel";
import { MailProfilePolicyEditor, errorMessage, MAIL_PROFILE_DOCUMENTATION, scopeLabel, transportLabel, scopeOrder } from "./MailProfilePolicyEditor";
export { MailProfilePolicyEditor } from "./MailProfilePolicyEditor";
export type MailProfileTargetOption = {
id: string;
label: string;
@@ -131,23 +74,8 @@ type MailProfileScopeManagerProps = {
canWritePolicy: boolean;
};
type MailProfilePolicyEditorProps = {
settings: ApiSettings;
scopeType: MailProfileScope;
scopeId?: string | null;
campaignId?: string | null;
profiles: MailServerProfile[];
ownerUserId?: string | null;
ownerGroupId?: string | null;
canWrite: boolean;
locked?: boolean;
title?: string;
description?: string;
onSaved?: () => void | Promise<void>;
};
type EditingProfile = MailServerProfile | "new" | null;
type PolicyFlagValue = "inherit" | "allow" | "deny";
type MailProfileTreeRow =
{kind: "profile";id: string;profile: MailServerProfile;} |
{kind: "server";id: string;profile: MailServerProfile;protocol: MailProfileProtocol;server: MailServerEndpoint;} |
@@ -160,36 +88,13 @@ 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",
jmap_hosts: "JMAP hostnames",
envelope_senders: "i18n:govoplan-mail.envelope_senders.269065cd",
from_headers: "i18n:govoplan-mail.from_headers.b3ea473b",
recipient_domains: "i18n:govoplan-mail.recipient_domains.cb9b7b44"
};
const blankPolicy: MailProfilePolicy = {
allowed_profile_ids: [],
allow_user_profiles: null,
allow_group_profiles: null,
allow_campaign_profiles: null,
smtp_credentials: {},
imap_credentials: {},
whitelist: {},
blacklist: {},
allow_lower_level_limits: {}
};
export function MailProfileScopeManager({
settings,
@@ -856,296 +761,6 @@ export function MailProfileScopeManager({
}
export function MailProfilePolicyEditor({
settings,
scopeType,
scopeId = null,
campaignId = null,
profiles,
ownerUserId = null,
ownerGroupId = null,
canWrite,
locked = false,
title = "i18n:govoplan-mail.mail_profile_policy.f2ac4b92",
description = "i18n:govoplan-mail.allowed_profiles_and_wildcard_rules_for_this_sco.0f82b3e4",
onSaved
}: MailProfilePolicyEditorProps) {
const [policy, setPolicy] = useState<MailProfilePolicy>(blankPolicy);
const [effectivePolicy, setEffectivePolicy] = useState<MailProfilePolicy | null>(null);
const [parentPolicy, setParentPolicy] = useState<MailProfilePolicy | null>(null);
const [effectivePolicySources, setEffectivePolicySources] = useState<PolicySourcePathItem[]>([]);
const [savedPolicyKey, setSavedPolicyKey] = useState(policyDraftKey(blankPolicy));
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [profileEffectivePolicy, setProfileEffectivePolicy] = useState<MailProfilePolicy | null>(null);
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
const scopeReady = !requiresTarget || Boolean(scopeId);
const policyDirty = scopeReady && policyDraftKey(policy) !== savedPolicyKey;
useUnsavedDraftGuard({
dirty: policyDirty,
onSave: savePolicy,
onDiscard: () => setPolicy(JSON.parse(savedPolicyKey) as MailProfilePolicy)
});
useEffect(() => {void loadPolicy();}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId, campaignId]);
async function loadPolicy() {
setError("");
setSuccess("");
if (!scopeReady) {
setPolicy(blankPolicy);
setSavedPolicyKey(policyDraftKey(blankPolicy));
setEffectivePolicy(null);
setParentPolicy(null);
setEffectivePolicySources([]);
return;
}
setLoading(true);
try {
const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId);
const loadedPolicy = normalizePolicy(response.policy);
setPolicy(loadedPolicy);
setSavedPolicyKey(policyDraftKey(loadedPolicy));
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null);
setEffectivePolicySources(response.effective_policy_sources ?? []);
} catch (err) {
setPolicy(blankPolicy);
setSavedPolicyKey(policyDraftKey(blankPolicy));
setEffectivePolicy(null);
setParentPolicy(null);
setEffectivePolicySources([]);
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
async function savePolicy(): Promise<boolean> {
if (!scopeReady) return false;
setBusy(true);
setError("");
setSuccess("");
try {
const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicyForSave(policy, parentPolicy, scopeType), scopeId);
const savedPolicy = normalizePolicy(response.policy);
setPolicy(savedPolicy);
setSavedPolicyKey(policyDraftKey(savedPolicy));
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null);
setEffectivePolicySources(response.effective_policy_sources ?? []);
setSuccess("i18n:govoplan-mail.mail_profile_policy_saved.666847bf");
await onSaved?.();
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setBusy(false);
}
}
const candidateProfiles = useMemo(
() => profileCandidatesForPolicy(profiles, scopeType, scopeId, ownerUserId, ownerGroupId),
[ownerGroupId, ownerUserId, profiles, scopeId, scopeType]
);
const isSystem = scopeType === "system";
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;
const parentBlocksCampaignProfiles = parentPolicy?.allow_campaign_profiles === false;
const showAllowColumn = scopeType !== "campaign";
const showEffectiveColumn = !isSystem;
const profileAllowListLocked = !parentAllowsMailLimit("allowed_profile_ids");
const blockedProfileDefinitions = [
parentBlocksUserProfiles ? "user" : "",
parentBlocksGroupProfiles ? "group" : "",
parentBlocksCampaignProfiles ? "i18n:govoplan-mail.campaign_local_settings.920ecb62" : ""].
filter(Boolean).join(", ");
const effectivePolicyPath = effectivePolicySources.length > 0 ? effectivePolicySources : mailPolicySourcePath(scopeType);
function patchPolicy(patch: Partial<MailProfilePolicy>) {
setPolicy((current) => normalizePolicy({ ...current, ...patch }));
}
function setFlag(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", value: PolicyFlagValue) {
patchPolicy({ [key]: flagToBoolean(value) });
}
function setPattern(kind: "whitelist" | "blacklist", key: MailProfilePatternKey, text: string) {
const nextRules = { ...(policy[kind] ?? {}) };
const parsed = parsePatternList(text);
if (parsed.length > 0) nextRules[key] = parsed;else
delete nextRules[key];
patchPolicy({ [kind]: nextRules });
}
function parentAllowsMailLimit(key: MailProfilePolicyLimitKey): boolean {
return !parentPolicy || parentPolicy.allow_lower_level_limits?.[key] !== false;
}
function localAllowsMailLimit(key: MailProfilePolicyLimitKey): boolean {
const localValue = policy.allow_lower_level_limits?.[key];
if (localValue !== undefined) return localValue && parentAllowsMailLimit(key);
return parentAllowsMailLimit(key);
}
function setAllowLowerLevelLimit(key: MailProfilePolicyLimitKey, allowed: boolean) {
patchPolicy({ allow_lower_level_limits: { ...(policy.allow_lower_level_limits ?? {}), [key]: allowed } });
}
function lowerLevelLimitToggle(key: MailProfilePolicyLimitKey, label: ReactNode = "i18n:govoplan-mail.allow_override.ffa6e9a0"): ReactNode | undefined {
if (!showAllowColumn) return undefined;
const parentLocked = !parentAllowsMailLimit(key);
return (
<ToggleSwitch
checked={localAllowsMailLimit(key)}
disabled={disabled || parentLocked}
onChange={(checked) => setAllowLowerLevelLimit(key, checked)}
label={label} />);
}
return (
<Card
title={title}
actions={
<div className="button-row compact-actions">
<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>}
<section className="mail-policy-section policy-section">
<ActionToolbar surface="section-header" className="subsection-heading split">
<h3>i18n:govoplan-mail.profile_allow_list.507dfe6c</h3>
<div className="button-row compact-actions">
{lowerLevelLimitToggle("allowed_profile_ids")}
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || profileAllowListLocked || selectedProfileIds.size === 0}>i18n:govoplan-mail.clear_allow_list.f69c8c67</Button>
</div>
</ActionToolbar>
<p className="muted small-note">{selectedProfileIds.size === 0 ? "i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39" : i18nMessage("i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44", { value0: selectedProfileIds.size })}</p>
<AdminSelectionList
options={candidateProfiles.map((profile) => ({
id: profile.id,
label: profile.name,
description: `${scopeLabel(profile)} · ${transportLabel(profile.smtp)}`,
disabled: disabled || profileAllowListLocked || Boolean(parentAllowedProfileIds && !parentAllowedProfileIds.has(profile.id) && !selectedProfileIds.has(profile.id))
}))}
selected={[...selectedProfileIds]}
onChange={(allowedProfileIds) => patchPolicy({ allowed_profile_ids: [...allowedProfileIds].sort() })}
emptyText="i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85"
/>
{parentAllowedProfileIds && <p className="muted small-note">i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179 {parentAllowedProfileIds.size} i18n:govoplan-mail.profile_s.742e9200</p>}
</section>
<section className="mail-policy-section policy-section">
<h3>i18n:govoplan-mail.lower_level_mail_definitions.d39a0a1d</h3>
<PolicyTable className="mail-policy-table" rowClassName="mail-policy-row" headerClassName="mail-policy-row-header" fieldLabel="i18n:govoplan-mail.policy.bb9cf141" settingLabel={isSystem ? "i18n:govoplan-mail.value.8dce170d" : "i18n:govoplan-mail.local_setting.967607a9"} effectiveLabel="i18n:govoplan-mail.effective_policy.feedb950" lowerLevelLabel="i18n:govoplan-mail.lower_levels.940821ee" showAllowColumn={showAllowColumn} showEffectiveColumn={showEffectiveColumn}>
<PolicyRow
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label="i18n:govoplan-mail.user_profiles.57730285"
help={policyHelp("i18n:govoplan-mail.controls_whether_user_scoped_mail_profiles_may_b.00c0e0e7")}
control={<PolicyFlagControl value={booleanToFlag(displayPolicy.allow_user_profiles)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && !parentAllowsMailLimit("allow_user_profiles")} allowDisabled={parentBlocksUserProfiles} onChange={(value) => setFlag("allow_user_profiles", value)} />}
effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_user_profiles, effectivePolicy) : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle("allow_user_profiles")}</div> : undefined}
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_user_profiles", effectivePolicyPath) : undefined} />
<PolicyRow
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label="i18n:govoplan-mail.group_profiles.74568838"
help={policyHelp("i18n:govoplan-mail.controls_whether_group_scoped_mail_profiles_may_.0b832ea4")}
control={<PolicyFlagControl value={booleanToFlag(displayPolicy.allow_group_profiles)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && !parentAllowsMailLimit("allow_group_profiles")} allowDisabled={parentBlocksGroupProfiles} onChange={(value) => setFlag("allow_group_profiles", value)} />}
effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_group_profiles, effectivePolicy) : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle("allow_group_profiles")}</div> : undefined}
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_group_profiles", effectivePolicyPath) : undefined} />
<PolicyRow
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label="i18n:govoplan-mail.campaign_local_settings.eb0f1061"
help={policyHelp("i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc")}
control={<PolicyFlagControl value={booleanToFlag(displayPolicy.allow_campaign_profiles)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && !parentAllowsMailLimit("allow_campaign_profiles")} allowDisabled={parentBlocksCampaignProfiles} onChange={(value) => setFlag("allow_campaign_profiles", value)} />}
effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_campaign_profiles, effectivePolicy) : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle("allow_campaign_profiles")}</div> : undefined}
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_campaign_profiles", effectivePolicyPath) : undefined} />
</PolicyTable>
{blockedProfileDefinitions && <PolicyLockedHint>i18n:govoplan-mail.explicit_allow_is_unavailable_for.8d05fd4a {blockedProfileDefinitions} i18n:govoplan-mail.because_an_ancestor_policy_blocks_those_definiti.5de3e30d</PolicyLockedHint>}
</section>
<section className="mail-policy-section policy-section">
<h3>i18n:govoplan-mail.wildcard_rules.54fb3fc0</h3>
<div className={`mail-policy-pattern-table policy-table${showAllowColumn ? " with-allow-column" : ""}`}>
<div className="mail-policy-pattern-row policy-row mail-policy-row-header policy-row-header">
<span>i18n:govoplan-mail.policy_target.a19dcee9</span>
<span>i18n:govoplan-mail.whitelist.53c2ad30</span>
<span>i18n:govoplan-mail.blacklist.7b2dd04c</span>
{showAllowColumn && <span>i18n:govoplan-mail.lower_levels.940821ee</span>}
</div>
{mailProfilePatternKeys.map((key) =>
<div className="mail-policy-pattern-row policy-row" key={key}>
<div className="mail-policy-field-label policy-field-label">
<FieldLabel className="mail-policy-field-title policy-field-title" help={policyHelp(patternPolicyNote(key))}>{patternLabels[key]}</FieldLabel>
</div>
<PatternTextareaControl value={patternsToText(policy.whitelist?.[key])} disabled={disabled || !parentAllowsMailLimit(`whitelist.${key}` as MailProfilePolicyLimitKey)} onChange={(text) => setPattern("whitelist", key, text)} />
<PatternTextareaControl value={patternsToText(policy.blacklist?.[key])} disabled={disabled || !parentAllowsMailLimit(`blacklist.${key}` as MailProfilePolicyLimitKey)} onChange={(text) => setPattern("blacklist", key, text)} />
{showAllowColumn &&
<div className="mail-policy-pattern-limits">
{lowerLevelLimitToggle(i18nMessage("i18n:govoplan-mail.whitelist_value.d4cc3755", { value0: key }) as MailProfilePolicyLimitKey, "i18n:govoplan-mail.whitelist.53c2ad30")}
{lowerLevelLimitToggle(i18nMessage("i18n:govoplan-mail.blacklist_value.556334d0", { value0: key }) as MailProfilePolicyLimitKey, "i18n:govoplan-mail.blacklist.7b2dd04c")}
</div>
}
</div>
)}
</div>
</section>
{showEffectiveColumn && effectivePolicy &&
<section className="mail-policy-section policy-section mail-policy-effective">
<h3>i18n:govoplan-mail.policy_path.1ba91ee5</h3>
<PolicySourcePath items={effectivePolicyPath} />
<p className="muted small-note">i18n:govoplan-mail.effective_values_are_shown_in_the_table_rows_abo.b27b900d</p>
</section>
}
</div>
</LoadingFrame>
</Card>);
}
function ProfileForm({
settings,
@@ -1632,20 +1247,7 @@ function ProfileForm({
}
function PolicyFlagControl({ value, disabled, includeInherit = true, inheritOnly = false, allowDisabled = false, onChange }: {value: PolicyFlagValue;disabled: boolean;includeInherit?: boolean;inheritOnly?: boolean;allowDisabled?: boolean;onChange: (value: PolicyFlagValue) => void;}) {
const selectedValue = inheritOnly ? "inherit" : value;
return (
<select value={selectedValue} disabled={disabled} onChange={(event) => onChange(event.target.value as PolicyFlagValue)}>
{(includeInherit || inheritOnly) && <option value="inherit">i18n:govoplan-mail.inherit.18f99833</option>}
{!inheritOnly && <option value="allow" disabled={allowDisabled}>i18n:govoplan-mail.explicit_allow.6a7946f8</option>}
{!inheritOnly && <option value="deny">i18n:govoplan-mail.deny.53577bb5</option>}
</select>);
}
function PatternTextareaControl({ value, disabled, onChange }: {value: string;disabled: boolean;onChange: (value: string) => void;}) {
return <textarea rows={3} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} placeholder="*.example.org" />;
}
function targetPluralLabel(scopeType: MailProfileScope, fallback: string): string {
if (scopeType === "user") return "i18n:govoplan-mail.users";
@@ -1982,50 +1584,7 @@ function mailProfileEditorDisabledReason(
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>(
@@ -2079,123 +1638,14 @@ function hasDraftImapSettings(draft: ProfileDraft): boolean {
return hasMailImapSettings([draft.imapHost]);
}
function normalizePolicy(value: MailProfilePolicy | null | undefined): MailProfilePolicy {
return {
allowed_profile_ids: [...(value?.allowed_profile_ids ?? [])].filter(Boolean),
allow_user_profiles: value?.allow_user_profiles ?? null,
allow_group_profiles: value?.allow_group_profiles ?? null,
allow_campaign_profiles: value?.allow_campaign_profiles ?? null,
smtp_credentials: normalizeCredentialPolicy(value?.smtp_credentials),
imap_credentials: normalizeCredentialPolicy(value?.imap_credentials),
whitelist: normalizeRules(value?.whitelist),
blacklist: normalizeRules(value?.blacklist),
allow_lower_level_limits: normalizeMailLowerLevelLimits(value?.allow_lower_level_limits)
};
}
function policyDraftKey(policy: MailProfilePolicy): string {
return JSON.stringify(normalizePolicy(policy));
}
function normalizePolicyForSave(policy: MailProfilePolicy, parentPolicy: MailProfilePolicy | null, scopeType: MailProfileScope): MailProfilePolicy {
const normalized = normalizePolicy(policy);
if (scopeType === "system") return concreteSystemPolicy(normalized);
const localLimits = { ...(normalized.allow_lower_level_limits ?? {}) };
const parentLimits = parentPolicy?.allow_lower_level_limits ?? null;
function parentAllows(key: MailProfilePolicyLimitKey): boolean {
return !parentLimits || parentLimits[key] !== false;
}
function clearLimit(key: MailProfilePolicyLimitKey) {
delete localLimits[key];
}
if (!parentAllows("allowed_profile_ids")) {
normalized.allowed_profile_ids = [];
clearLimit("allowed_profile_ids");
}
for (const key of ["allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"] as const) {
if (!parentAllows(key)) {
normalized[key] = null;
clearLimit(key);
}
}
for (const protocol of ["smtp_credentials", "imap_credentials"] as const) {
const credential = normalizeCredentialPolicy(normalized[protocol]);
const inheritKey = `${protocol}.inherit` as MailProfilePolicyLimitKey;
if (!parentAllows(inheritKey)) {
credential.inherit = null;
clearLimit(inheritKey);
}
normalized[protocol] = credential;
}
for (const key of mailProfilePatternKeys) {
const whitelistKey = `whitelist.${key}` as MailProfilePolicyLimitKey;
const blacklistKey = `blacklist.${key}` as MailProfilePolicyLimitKey;
if (!parentAllows(whitelistKey)) {
delete normalized.whitelist?.[key];
clearLimit(whitelistKey);
}
if (!parentAllows(blacklistKey)) {
delete normalized.blacklist?.[key];
clearLimit(blacklistKey);
}
}
if (scopeType === "campaign") {
normalized.allow_lower_level_limits = {};
} else {
normalized.allow_lower_level_limits = localLimits;
}
return normalized;
}
function normalizeMailLowerLevelLimits(value: MailProfilePolicy["allow_lower_level_limits"]): Partial<Record<MailProfilePolicyLimitKey, boolean>> {
const result: Partial<Record<MailProfilePolicyLimitKey, boolean>> = {};
for (const key of mailProfilePolicyLimitKeys) {
if (typeof value?.[key] === "boolean") result[key] = value[key];
}
return result;
}
function fullMailLowerLevelLimits(value: MailProfilePolicy["allow_lower_level_limits"]): Record<MailProfilePolicyLimitKey, boolean> {
const result = {} as Record<MailProfilePolicyLimitKey, boolean>;
for (const key of mailProfilePolicyLimitKeys) {
result[key] = value?.[key] !== false;
}
return result;
}
function concreteSystemPolicy(policy: MailProfilePolicy): MailProfilePolicy {
const normalized = normalizePolicy(policy);
return {
...normalized,
allow_user_profiles: normalized.allow_user_profiles ?? true,
allow_group_profiles: normalized.allow_group_profiles ?? true,
allow_campaign_profiles: normalized.allow_campaign_profiles ?? true,
smtp_credentials: concreteSystemCredentialPolicy(normalized.smtp_credentials),
imap_credentials: concreteSystemCredentialPolicy(normalized.imap_credentials),
allow_lower_level_limits: fullMailLowerLevelLimits(normalized.allow_lower_level_limits)
};
}
function concreteSystemCredentialPolicy(value: MailCredentialPolicy | null | undefined): MailCredentialPolicy {
const normalized = normalizeCredentialPolicy(value);
return { inherit: normalized.inherit ?? true };
}
function normalizeCredentialPolicy(value: MailCredentialPolicy | null | undefined): MailCredentialPolicy {
return { inherit: typeof value?.inherit === "boolean" ? value.inherit : null };
}
function normalizeRules(value: MailProfilePatternRules | null | undefined): MailProfilePatternRules {
const result: MailProfilePatternRules = {};
for (const key of mailProfilePatternKeys) {
const patterns = (value?.[key] ?? []).map((pattern) => pattern.trim()).filter(Boolean);
if (patterns.length > 0) result[key] = patterns;
}
return result;
}
function profileBelongsToEditableScope(profile: MailServerProfile, scopeType: MailProfileScope, scopeId: string | null): boolean {
if (profile.scope_type !== scopeType) return false;
@@ -2204,135 +1654,29 @@ function profileBelongsToEditableScope(profile: MailServerProfile, scopeType: Ma
return Boolean(scopeId) && profile.scope_id === scopeId;
}
function profileCandidatesForPolicy(profiles: MailServerProfile[], scopeType: MailProfileScope, scopeId: string | null, ownerUserId: string | null, ownerGroupId: string | null): MailServerProfile[] {
return profiles.
filter((profile) => {
if (scopeType === "system") return profile.scope_type === "system";
if (profile.scope_type === "system" || profile.scope_type === "tenant") return true;
if (scopeType === "user") return profile.scope_type === "user" && profile.scope_id === scopeId;
if (scopeType === "group") return profile.scope_type === "group" && profile.scope_id === scopeId;
if (scopeType === "campaign") {
if (profile.scope_type === "campaign") return profile.scope_id === scopeId;
if (profile.scope_type === "user") return Boolean(ownerUserId) && profile.scope_id === ownerUserId;
if (profile.scope_type === "group") return Boolean(ownerGroupId) && profile.scope_id === ownerGroupId;
}
return false;
}).
sort((a, b) => `${scopeOrder(a.scope_type)}:${a.name}`.localeCompare(`${scopeOrder(b.scope_type)}:${b.name}`));
}
function scopeOrder(scopeType: MailProfileScope): number {
if (scopeType === "system") return 0;
if (scopeType === "tenant") return 1;
if (scopeType === "user" || scopeType === "group") return 2;
return 3;
}
function policyHelp(description: string): ReactNode {
return <span>{description}</span>;
}
function mailBooleanPolicyPathHelp(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", sources: PolicySourcePathItem[]): ReactNode {
return <PolicyPathHelp lines={mailBooleanPolicyPathLines(key, normalizePolicySourcePathItems(sources))} />;
}
function mailBooleanPolicyPathLines(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", items: NormalizedPolicySourcePathItem[]): string[] {
if (items.length === 0) return ["i18n:govoplan-mail.system_allow.ed6744b1"];
const lines: string[] = [];
for (const [index, item] of items.entries()) {
const policy = policySourceRecord(item);
const rawValue = policy[key];
const value = rawValue === true ? "i18n:govoplan-mail.allow.3ad0e369" : rawValue === false ? "i18n:govoplan-mail.deny.53577bb5" : "i18n:govoplan-mail.inherit.18f99833";
const lowerLocked = asRecord(policy.allow_lower_level_limits)[key] === false;
const stops = rawValue === false || lowerLocked;
lines.push(`${policyPathPrefix(index)}${item.label}: ${stops ? `${value} without override` : value}`);
if (stops) break;
}
return lines;
}
function policySourceRecord(item: NormalizedPolicySourcePathItem): Record<string, unknown> {
return asRecord(item.policy);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function policyPathPrefix(index: number): string {
return index === 0 ? "" : `${" ".repeat(index - 1)}> `;
}
function effectiveBooleanLabel(value: boolean | null | undefined, policy: MailProfilePolicy | null): string {
if (!policy) return "i18n:govoplan-mail.loading.b04ba49f";
return value ? "i18n:govoplan-mail.allowed.77c7b490" : "i18n:govoplan-mail.blocked.99613c74";
}
function patternPolicyNote(key: MailProfilePatternKey): string {
if (key === "smtp_hosts") return "i18n:govoplan-mail.smtp_server_host_patterns.cf6120c3";
if (key === "imap_hosts") return "i18n:govoplan-mail.imap_server_host_patterns.52b20b83";
if (key === "envelope_senders") return "i18n:govoplan-mail.smtp_envelope_sender_patterns.8c1fd95e";
if (key === "from_headers") return "i18n:govoplan-mail.visible_from_header_patterns.ea77d99d";
return "i18n:govoplan-mail.recipient_domain_patterns.68466f5b";
}
function booleanToFlag(value: boolean | null | undefined): PolicyFlagValue {
if (value === true) return "allow";
if (value === false) return "deny";
return "inherit";
}
function flagToBoolean(value: PolicyFlagValue): boolean | null {
if (value === "allow") return true;
if (value === "deny") return false;
return null;
}
function parsePatternList(value: string): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const item of value.split(/[\n,]+/)) {
const pattern = item.trim();
if (pattern && !seen.has(pattern)) {
seen.add(pattern);
result.push(pattern);
}
}
return result;
}
function patternsToText(value: string[] | undefined): string {
return (value ?? []).join("\n");
}
function effectiveProfileLabel(value: string[] | null | undefined): string {
if (!value || value.length === 0) return "i18n:govoplan-mail.no_explicit_intersection.f2d62c71";
return i18nMessage("i18n:govoplan-mail.value_profile_s.d6b9d0af", { value0: value.length });
}
function mailPolicySourcePath(scopeType: MailProfileScope): string[] {
if (scopeType === "system") return ["i18n:govoplan-mail.system.bc0792d8"];
if (scopeType === "tenant") return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78"];
if (scopeType === "user") return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.user.9f8a2389"];
if (scopeType === "group") return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.group.171a0606"];
return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.owner_policy.1e8df143", "i18n:govoplan-mail.campaign.69390e16"];
}
function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | MailJmapTransportSettings | null | undefined): string {
if (!transport) return "i18n:govoplan-mail.not_configured.811931bb";
if ("session_url" in transport) return transport.session_url || "No Session URL";
const host = transport.host || "i18n:govoplan-mail.no_host.4c710d7d";
const port = transport.port ? `:${transport.port}` : "";
return `${host}${port}`;
}
function scopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "system") return "i18n:govoplan-mail.system.bc0792d8";
if (profile.scope_type === "tenant") return "i18n:govoplan-mail.tenant.3ca93c78";
if (profile.scope_type === "user") return "i18n:govoplan-mail.user.9f8a2389";
if (profile.scope_type === "group") return "i18n:govoplan-mail.group.171a0606";
return "i18n:govoplan-mail.campaign.69390e16";
}
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
return normalizeMailServerSecurity<MailSecurity>(value, { fallback, allowedSecurity: securityOptions });
@@ -2365,7 +1709,3 @@ function jmapHostname(value: string): string {
return "";
}
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
@@ -0,0 +1,712 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { ActionToolbar, ActionBlockerHint, AdminSelectionList, DocumentationHelpLink, FieldLabel, LoadingFrame, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, ToggleSwitch, normalizePolicySourcePathItems, type MailJmapTransportSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import { getMailProfilePolicy, mailProfilePatternKeys, mailProfilePolicyLimitKeys, updateMailProfilePolicy, type MailCredentialPolicy, type MailImapTestPayload, type MailProfilePatternKey, type MailProfilePatternRules, type MailProfilePolicy, type MailProfilePolicyLimitKey, type MailProfileScope, type MailServerProfile, type MailSmtpTestPayload } from "../../api/mail";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { i18nMessage, usePlatformLanguage, useUnsavedDraftGuard } from "@govoplan/core-webui";
type MailProfilePolicyEditorProps = {
settings: ApiSettings;
scopeType: MailProfileScope;
scopeId?: string | null;
campaignId?: string | null;
profiles: MailServerProfile[];
ownerUserId?: string | null;
ownerGroupId?: string | null;
canWrite: boolean;
locked?: boolean;
title?: string;
description?: string;
onSaved?: () => void | Promise<void>;
};
type PolicyFlagValue = "inherit" | "allow" | "deny";
export const MAIL_PROFILE_DOCUMENTATION = {
topicId: "mail.profiles-and-policy",
documentationType: "admin"
} as const;
const patternLabels: Record<MailProfilePatternKey, string> = {
smtp_hosts: "i18n:govoplan-mail.smtp_hostnames.36eb51d8",
imap_hosts: "i18n:govoplan-mail.imap_hostnames.ac9c1d78",
jmap_hosts: "JMAP hostnames",
envelope_senders: "i18n:govoplan-mail.envelope_senders.269065cd",
from_headers: "i18n:govoplan-mail.from_headers.b3ea473b",
recipient_domains: "i18n:govoplan-mail.recipient_domains.cb9b7b44"
};
const blankPolicy: MailProfilePolicy = {
allowed_profile_ids: [],
allow_user_profiles: null,
allow_group_profiles: null,
allow_campaign_profiles: null,
smtp_credentials: {},
imap_credentials: {},
whitelist: {},
blacklist: {},
allow_lower_level_limits: {}
};
export function MailProfilePolicyEditor({
settings,
scopeType,
scopeId = null,
campaignId = null,
profiles,
ownerUserId = null,
ownerGroupId = null,
canWrite,
locked = false,
title = "i18n:govoplan-mail.mail_profile_policy.f2ac4b92",
description = "i18n:govoplan-mail.allowed_profiles_and_wildcard_rules_for_this_sco.0f82b3e4",
onSaved
}: MailProfilePolicyEditorProps) {
const { translateText } = usePlatformLanguage();
const [policy, setPolicy] = useState<MailProfilePolicy>(blankPolicy);
const [effectivePolicy, setEffectivePolicy] = useState<MailProfilePolicy | null>(null);
const [parentPolicy, setParentPolicy] = useState<MailProfilePolicy | null>(null);
const [effectivePolicySources, setEffectivePolicySources] = useState<PolicySourcePathItem[]>([]);
const [savedPolicyKey, setSavedPolicyKey] = useState(policyDraftKey(blankPolicy));
const [loading, setLoading] = useState(false);
const [policyLoaded, setPolicyLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [refreshWarning, setRefreshWarning] = useState("");
const requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
const scopeReady = !requiresTarget || Boolean(scopeId);
const policyDirty = scopeReady && policyDraftKey(policy) !== savedPolicyKey;
useUnsavedDraftGuard({
dirty: policyDirty,
onSave: savePolicy,
onDiscard: () => setPolicy(JSON.parse(savedPolicyKey) as MailProfilePolicy)
});
useEffect(() => {void loadPolicy();}, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId, campaignId]);
async function loadPolicy() {
setPolicyLoaded(false);
setError("");
setSuccess("");
setRefreshWarning("");
if (!scopeReady) {
setPolicy(blankPolicy);
setSavedPolicyKey(policyDraftKey(blankPolicy));
setEffectivePolicy(null);
setParentPolicy(null);
setEffectivePolicySources([]);
return;
}
setLoading(true);
try {
const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId);
const loadedPolicy = normalizePolicy(response.policy);
setPolicy(loadedPolicy);
setSavedPolicyKey(policyDraftKey(loadedPolicy));
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null);
setEffectivePolicySources(response.effective_policy_sources ?? []);
setPolicyLoaded(true);
} catch (err) {
setPolicy(blankPolicy);
setSavedPolicyKey(policyDraftKey(blankPolicy));
setEffectivePolicy(null);
setParentPolicy(null);
setEffectivePolicySources([]);
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
async function savePolicy(): Promise<boolean> {
if (!scopeReady || !canWrite || locked || loading || !policyLoaded || busy) return false;
setBusy(true);
setError("");
setSuccess("");
setRefreshWarning("");
try {
const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicyForSave(policy, parentPolicy, scopeType), scopeId);
const savedPolicy = normalizePolicy(response.policy);
setPolicy(savedPolicy);
setSavedPolicyKey(policyDraftKey(savedPolicy));
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null);
setEffectivePolicySources(response.effective_policy_sources ?? []);
setSuccess("i18n:govoplan-mail.mail_profile_policy_saved.666847bf");
try {
await onSaved?.();
} catch (refreshError) {
// The policy write has committed. A dependent refresh must never turn
// this into a failed save or encourage replaying the accepted write.
setRefreshWarning(i18nMessage("i18n:govoplan-mail.policy_saved_refresh_failed", { value0: errorMessage(refreshError) }));
}
return true;
} catch (err) {
setError(errorMessage(err));
return false;
} finally {
setBusy(false);
}
}
const candidateProfiles = useMemo(
() => profileCandidatesForPolicy(profiles, scopeType, scopeId, ownerUserId, ownerGroupId),
[ownerGroupId, ownerUserId, profiles, scopeId, scopeType]
);
const isSystem = scopeType === "system";
const displayPolicy = useMemo(() => isSystem ? concreteSystemPolicy(policy) : policy, [isSystem, policy]);
const selectedProfileIds = new Set(policy.allowed_profile_ids ?? []);
const disabled = locked || busy || loading || !policyLoaded || !canWrite || !scopeReady;
const policySaveBlocker = mailPolicyDisabledReason(locked, canWrite, scopeReady, loading || !policyLoaded, 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;
const parentBlocksCampaignProfiles = parentPolicy?.allow_campaign_profiles === false;
const showAllowColumn = scopeType !== "campaign";
const showEffectiveColumn = !isSystem;
const profileAllowListLocked = !parentAllowsMailLimit("allowed_profile_ids");
const blockedProfileDefinitions = [
parentBlocksUserProfiles ? "user" : "",
parentBlocksGroupProfiles ? "group" : "",
parentBlocksCampaignProfiles ? "i18n:govoplan-mail.campaign_local_settings.920ecb62" : ""].
filter(Boolean).join(", ");
const effectivePolicyPath = effectivePolicySources.length > 0 ? effectivePolicySources : mailPolicySourcePath(scopeType);
function patchPolicy(patch: Partial<MailProfilePolicy>) {
setPolicy((current) => normalizePolicy({ ...current, ...patch }));
}
function setFlag(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", value: PolicyFlagValue) {
patchPolicy({ [key]: flagToBoolean(value) });
}
function setPattern(kind: "whitelist" | "blacklist", key: MailProfilePatternKey, text: string) {
const nextRules = { ...(policy[kind] ?? {}) };
const parsed = parsePatternList(text);
if (parsed.length > 0) nextRules[key] = parsed;else
delete nextRules[key];
patchPolicy({ [kind]: nextRules });
}
function parentAllowsMailLimit(key: MailProfilePolicyLimitKey): boolean {
return !parentPolicy || parentPolicy.allow_lower_level_limits?.[key] !== false;
}
function localAllowsMailLimit(key: MailProfilePolicyLimitKey): boolean {
const localValue = policy.allow_lower_level_limits?.[key];
if (localValue !== undefined) return localValue && parentAllowsMailLimit(key);
return parentAllowsMailLimit(key);
}
function setAllowLowerLevelLimit(key: MailProfilePolicyLimitKey, allowed: boolean) {
patchPolicy({ allow_lower_level_limits: { ...(policy.allow_lower_level_limits ?? {}), [key]: allowed } });
}
function lowerLevelLimitToggle(key: MailProfilePolicyLimitKey, label: ReactNode = "i18n:govoplan-mail.allow_override.ffa6e9a0"): ReactNode | undefined {
if (!showAllowColumn) return undefined;
const parentLocked = !parentAllowsMailLimit(key);
return (
<ToggleSwitch
checked={localAllowsMailLimit(key)}
disabled={disabled || parentLocked}
onChange={(checked) => setAllowLowerLevelLimit(key, checked)}
label={label} />);
}
return (
<Card
title={title}
actions={
<div className="button-row compact-actions">
<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>}
{refreshWarning && <DismissibleAlert tone="warning" resetKey={refreshWarning}>{refreshWarning}</DismissibleAlert>}
<section className="mail-policy-section policy-section">
<ActionToolbar surface="section-header" className="subsection-heading split">
<h3>i18n:govoplan-mail.profile_allow_list.507dfe6c</h3>
<div className="button-row compact-actions">
{lowerLevelLimitToggle("allowed_profile_ids")}
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || profileAllowListLocked || selectedProfileIds.size === 0}>i18n:govoplan-mail.clear_allow_list.f69c8c67</Button>
</div>
</ActionToolbar>
<p className="muted small-note">{selectedProfileIds.size === 0 ? "i18n:govoplan-mail.no_local_profile_allow_list_is_set.31072e39" : i18nMessage("i18n:govoplan-mail.value_profile_s_allowed_by_this_scope.6fe9ba44", { value0: selectedProfileIds.size })}</p>
<AdminSelectionList
options={candidateProfiles.map((profile) => ({
id: profile.id,
label: profile.name,
description: `${scopeLabel(profile)} · ${transportLabel(profile.smtp)}`,
disabled: disabled || profileAllowListLocked || Boolean(parentAllowedProfileIds && !parentAllowedProfileIds.has(profile.id) && !selectedProfileIds.has(profile.id))
}))}
selected={[...selectedProfileIds]}
onChange={(allowedProfileIds) => patchPolicy({ allowed_profile_ids: [...allowedProfileIds].sort() })}
emptyText="i18n:govoplan-mail.no_profiles_are_visible_for_this_policy_scope.1ec7bd85"
/>
{parentAllowedProfileIds && <p className="muted small-note">i18n:govoplan-mail.an_ancestor_allow_list_limits_selectable_profile.499ec179 {parentAllowedProfileIds.size} i18n:govoplan-mail.profile_s.742e9200</p>}
</section>
<section className="mail-policy-section policy-section">
<h3>i18n:govoplan-mail.lower_level_mail_definitions.d39a0a1d</h3>
<PolicyTable className="mail-policy-table" rowClassName="mail-policy-row" headerClassName="mail-policy-row-header" fieldLabel="i18n:govoplan-mail.policy.bb9cf141" settingLabel={isSystem ? "i18n:govoplan-mail.value.8dce170d" : "i18n:govoplan-mail.local_setting.967607a9"} effectiveLabel="i18n:govoplan-mail.effective_policy.feedb950" lowerLevelLabel="i18n:govoplan-mail.lower_levels.940821ee" showAllowColumn={showAllowColumn} showEffectiveColumn={showEffectiveColumn}>
<PolicyRow
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label="i18n:govoplan-mail.user_profiles.57730285"
help={policyHelp("i18n:govoplan-mail.controls_whether_user_scoped_mail_profiles_may_b.00c0e0e7")}
control={<PolicyFlagControl value={booleanToFlag(displayPolicy.allow_user_profiles)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && !parentAllowsMailLimit("allow_user_profiles")} allowDisabled={parentBlocksUserProfiles} onChange={(value) => setFlag("allow_user_profiles", value)} />}
effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_user_profiles, effectivePolicy) : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle("allow_user_profiles")}</div> : undefined}
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_user_profiles", effectivePolicyPath) : undefined} />
<PolicyRow
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label="i18n:govoplan-mail.group_profiles.74568838"
help={policyHelp("i18n:govoplan-mail.controls_whether_group_scoped_mail_profiles_may_.0b832ea4")}
control={<PolicyFlagControl value={booleanToFlag(displayPolicy.allow_group_profiles)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && !parentAllowsMailLimit("allow_group_profiles")} allowDisabled={parentBlocksGroupProfiles} onChange={(value) => setFlag("allow_group_profiles", value)} />}
effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_group_profiles, effectivePolicy) : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle("allow_group_profiles")}</div> : undefined}
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_group_profiles", effectivePolicyPath) : undefined} />
<PolicyRow
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label="i18n:govoplan-mail.campaign_local_settings.eb0f1061"
help={policyHelp("i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc")}
control={<PolicyFlagControl value={booleanToFlag(displayPolicy.allow_campaign_profiles)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && !parentAllowsMailLimit("allow_campaign_profiles")} allowDisabled={parentBlocksCampaignProfiles} onChange={(value) => setFlag("allow_campaign_profiles", value)} />}
effective={showEffectiveColumn ? effectiveBooleanLabel(effectivePolicy?.allow_campaign_profiles, effectivePolicy) : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle("allow_campaign_profiles")}</div> : undefined}
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_campaign_profiles", effectivePolicyPath) : undefined} />
</PolicyTable>
{blockedProfileDefinitions && <PolicyLockedHint>i18n:govoplan-mail.explicit_allow_is_unavailable_for.8d05fd4a {blockedProfileDefinitions} i18n:govoplan-mail.because_an_ancestor_policy_blocks_those_definiti.5de3e30d</PolicyLockedHint>}
</section>
<section className="mail-policy-section policy-section" data-testid="mail-credential-policy">
<h3>i18n:govoplan-mail.credential_selection_policy</h3>
<p className="muted small-note">i18n:govoplan-mail.credential_selection_policy_help</p>
<PolicyTable className="mail-policy-table" rowClassName="mail-policy-row" headerClassName="mail-policy-row-header" fieldLabel="i18n:govoplan-mail.policy.bb9cf141" settingLabel={isSystem ? "i18n:govoplan-mail.value.8dce170d" : "i18n:govoplan-mail.local_setting.967607a9"} effectiveLabel="i18n:govoplan-mail.effective_policy.feedb950" lowerLevelLabel="i18n:govoplan-mail.lower_levels.940821ee" showAllowColumn={showAllowColumn} showEffectiveColumn={showEffectiveColumn}>
{(["smtp", "imap"] as const).map((protocol) => {
const key = `${protocol}_credentials` as const;
const limitKey = `${key}.inherit` as MailProfilePolicyLimitKey;
const parentLocked = !parentAllowsMailLimit(limitKey);
const local = displayPolicy[key]?.inherit;
return <PolicyRow key={key}
className="mail-policy-row"
labelClassName="mail-policy-field-label"
controlClassName="mail-policy-control"
effectiveCellClassName="mail-policy-effective-cell"
effectiveClassName="mail-policy-effective-value"
label={`i18n:govoplan-mail.${protocol}_credential_selection`}
help={policyHelp("i18n:govoplan-mail.credential_selection_policy_help")}
control={<select aria-label={translateText(`i18n:govoplan-mail.${protocol}_credential_selection`)}
value={parentLocked || local == null ? "inherit" : local ? "profile" : "explicit"}
disabled={disabled || parentLocked}
onChange={(event) => patchPolicy({ [key]: { inherit: event.target.value === "inherit" ? null : event.target.value === "profile" } })}>
{(!isSystem || parentLocked) && <option value="inherit">i18n:govoplan-mail.credential_policy_parent</option>}
<option value="profile">i18n:govoplan-mail.credential_policy_profile</option>
<option value="explicit">i18n:govoplan-mail.credential_policy_explicit</option>
</select>}
effective={showEffectiveColumn ? effectivePolicy ? credentialSelectionLabel(effectivePolicy[key]?.inherit) : "i18n:govoplan-mail.loading.b04ba49f" : undefined}
effectiveHelp={showEffectiveColumn ? <PolicyPathHelp lines={mailCredentialPolicyPathLines(key, normalizePolicySourcePathItems(effectivePolicyPath))} /> : undefined}
allowControl={showAllowColumn ? <div className="mail-policy-lower-cell policy-lower-cell">{lowerLevelLimitToggle(limitKey)}</div> : undefined} />;
})}
</PolicyTable>
{(["smtp_credentials.inherit", "imap_credentials.inherit"] as const).some((key) => !parentAllowsMailLimit(key)) && <PolicyLockedHint>i18n:govoplan-mail.credential_policy_parent_locked</PolicyLockedHint>}
</section>
<section className="mail-policy-section policy-section">
<h3>i18n:govoplan-mail.wildcard_rules.54fb3fc0</h3>
<div className={`mail-policy-pattern-table policy-table${showAllowColumn ? " with-allow-column" : ""}`}>
<div className="mail-policy-pattern-row policy-row mail-policy-row-header policy-row-header">
<span>i18n:govoplan-mail.policy_target.a19dcee9</span>
<span>i18n:govoplan-mail.whitelist.53c2ad30</span>
<span>i18n:govoplan-mail.blacklist.7b2dd04c</span>
{showAllowColumn && <span>i18n:govoplan-mail.lower_levels.940821ee</span>}
</div>
{mailProfilePatternKeys.map((key) =>
<div className="mail-policy-pattern-row policy-row" key={key}>
<div className="mail-policy-field-label policy-field-label">
<FieldLabel className="mail-policy-field-title policy-field-title" help={policyHelp(patternPolicyNote(key))}>{patternLabels[key]}</FieldLabel>
</div>
<PatternTextareaControl value={patternsToText(policy.whitelist?.[key])} disabled={disabled || !parentAllowsMailLimit(`whitelist.${key}` as MailProfilePolicyLimitKey)} onChange={(text) => setPattern("whitelist", key, text)} />
<PatternTextareaControl value={patternsToText(policy.blacklist?.[key])} disabled={disabled || !parentAllowsMailLimit(`blacklist.${key}` as MailProfilePolicyLimitKey)} onChange={(text) => setPattern("blacklist", key, text)} />
{showAllowColumn &&
<div className="mail-policy-pattern-limits">
{lowerLevelLimitToggle(`whitelist.${key}` as MailProfilePolicyLimitKey, "i18n:govoplan-mail.whitelist.53c2ad30")}
{lowerLevelLimitToggle(`blacklist.${key}` as MailProfilePolicyLimitKey, "i18n:govoplan-mail.blacklist.7b2dd04c")}
</div>
}
</div>
)}
</div>
</section>
{showEffectiveColumn && effectivePolicy &&
<section className="mail-policy-section policy-section mail-policy-effective">
<h3>i18n:govoplan-mail.policy_path.1ba91ee5</h3>
<PolicySourcePath items={effectivePolicyPath} />
<p className="muted small-note">i18n:govoplan-mail.effective_values_are_shown_in_the_table_rows_abo.b27b900d</p>
</section>
}
</div>
</LoadingFrame>
</Card>);
}
function PolicyFlagControl({ value, disabled, includeInherit = true, inheritOnly = false, allowDisabled = false, onChange }: {value: PolicyFlagValue;disabled: boolean;includeInherit?: boolean;inheritOnly?: boolean;allowDisabled?: boolean;onChange: (value: PolicyFlagValue) => void;}) {
const selectedValue = inheritOnly ? "inherit" : value;
return (
<select value={selectedValue} disabled={disabled} onChange={(event) => onChange(event.target.value as PolicyFlagValue)}>
{(includeInherit || inheritOnly) && <option value="inherit">i18n:govoplan-mail.inherit.18f99833</option>}
{!inheritOnly && <option value="allow" disabled={allowDisabled}>i18n:govoplan-mail.explicit_allow.6a7946f8</option>}
{!inheritOnly && <option value="deny">i18n:govoplan-mail.deny.53577bb5</option>}
</select>);
}
function PatternTextareaControl({ value, disabled, onChange }: {value: string;disabled: boolean;onChange: (value: string) => void;}) {
return <textarea rows={3} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} placeholder="*.example.org" />;
}
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 normalizePolicy(value: MailProfilePolicy | null | undefined): MailProfilePolicy {
return {
allowed_profile_ids: [...(value?.allowed_profile_ids ?? [])].filter(Boolean),
allow_user_profiles: value?.allow_user_profiles ?? null,
allow_group_profiles: value?.allow_group_profiles ?? null,
allow_campaign_profiles: value?.allow_campaign_profiles ?? null,
smtp_credentials: normalizeCredentialPolicy(value?.smtp_credentials),
imap_credentials: normalizeCredentialPolicy(value?.imap_credentials),
whitelist: normalizeRules(value?.whitelist),
blacklist: normalizeRules(value?.blacklist),
allow_lower_level_limits: normalizeMailLowerLevelLimits(value?.allow_lower_level_limits)
};
}
function policyDraftKey(policy: MailProfilePolicy): string {
return JSON.stringify(normalizePolicy(policy));
}
function normalizePolicyForSave(policy: MailProfilePolicy, parentPolicy: MailProfilePolicy | null, scopeType: MailProfileScope): MailProfilePolicy {
const normalized = normalizePolicy(policy);
if (scopeType === "system") return concreteSystemPolicy(normalized);
const localLimits = { ...(normalized.allow_lower_level_limits ?? {}) };
const parentLimits = parentPolicy?.allow_lower_level_limits ?? null;
function parentAllows(key: MailProfilePolicyLimitKey): boolean {
return !parentLimits || parentLimits[key] !== false;
}
function clearLimit(key: MailProfilePolicyLimitKey) {
delete localLimits[key];
}
if (!parentAllows("allowed_profile_ids")) {
normalized.allowed_profile_ids = [];
clearLimit("allowed_profile_ids");
}
for (const key of ["allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"] as const) {
if (!parentAllows(key)) {
normalized[key] = null;
clearLimit(key);
}
}
for (const protocol of ["smtp_credentials", "imap_credentials"] as const) {
const credential = normalizeCredentialPolicy(normalized[protocol]);
const inheritKey = `${protocol}.inherit` as MailProfilePolicyLimitKey;
if (!parentAllows(inheritKey)) {
credential.inherit = null;
clearLimit(inheritKey);
}
normalized[protocol] = credential;
}
for (const key of mailProfilePatternKeys) {
const whitelistKey = `whitelist.${key}` as MailProfilePolicyLimitKey;
const blacklistKey = `blacklist.${key}` as MailProfilePolicyLimitKey;
if (!parentAllows(whitelistKey)) {
delete normalized.whitelist?.[key];
clearLimit(whitelistKey);
}
if (!parentAllows(blacklistKey)) {
delete normalized.blacklist?.[key];
clearLimit(blacklistKey);
}
}
if (scopeType === "campaign") {
normalized.allow_lower_level_limits = {};
} else {
normalized.allow_lower_level_limits = localLimits;
}
return normalized;
}
function normalizeMailLowerLevelLimits(value: MailProfilePolicy["allow_lower_level_limits"]): Partial<Record<MailProfilePolicyLimitKey, boolean>> {
const result: Partial<Record<MailProfilePolicyLimitKey, boolean>> = {};
for (const key of mailProfilePolicyLimitKeys) {
if (typeof value?.[key] === "boolean") result[key] = value[key];
}
return result;
}
function fullMailLowerLevelLimits(value: MailProfilePolicy["allow_lower_level_limits"]): Record<MailProfilePolicyLimitKey, boolean> {
const result = {} as Record<MailProfilePolicyLimitKey, boolean>;
for (const key of mailProfilePolicyLimitKeys) {
result[key] = value?.[key] !== false;
}
return result;
}
function concreteSystemPolicy(policy: MailProfilePolicy): MailProfilePolicy {
const normalized = normalizePolicy(policy);
return {
...normalized,
allow_user_profiles: normalized.allow_user_profiles ?? true,
allow_group_profiles: normalized.allow_group_profiles ?? true,
allow_campaign_profiles: normalized.allow_campaign_profiles ?? true,
smtp_credentials: concreteSystemCredentialPolicy(normalized.smtp_credentials),
imap_credentials: concreteSystemCredentialPolicy(normalized.imap_credentials),
allow_lower_level_limits: fullMailLowerLevelLimits(normalized.allow_lower_level_limits)
};
}
function concreteSystemCredentialPolicy(value: MailCredentialPolicy | null | undefined): MailCredentialPolicy {
const normalized = normalizeCredentialPolicy(value);
return { inherit: normalized.inherit ?? true };
}
function normalizeCredentialPolicy(value: MailCredentialPolicy | null | undefined): MailCredentialPolicy {
return { inherit: typeof value?.inherit === "boolean" ? value.inherit : null };
}
function normalizeRules(value: MailProfilePatternRules | null | undefined): MailProfilePatternRules {
const result: MailProfilePatternRules = {};
for (const key of mailProfilePatternKeys) {
const patterns = (value?.[key] ?? []).map((pattern) => pattern.trim()).filter(Boolean);
if (patterns.length > 0) result[key] = patterns;
}
return result;
}
function profileCandidatesForPolicy(profiles: MailServerProfile[], scopeType: MailProfileScope, scopeId: string | null, ownerUserId: string | null, ownerGroupId: string | null): MailServerProfile[] {
return profiles.
filter((profile) => {
if (scopeType === "system") return profile.scope_type === "system";
if (profile.scope_type === "system" || profile.scope_type === "tenant") return true;
if (scopeType === "user") return profile.scope_type === "user" && profile.scope_id === scopeId;
if (scopeType === "group") return profile.scope_type === "group" && profile.scope_id === scopeId;
if (scopeType === "campaign") {
if (profile.scope_type === "campaign") return profile.scope_id === scopeId;
if (profile.scope_type === "user") return Boolean(ownerUserId) && profile.scope_id === ownerUserId;
if (profile.scope_type === "group") return Boolean(ownerGroupId) && profile.scope_id === ownerGroupId;
}
return false;
}).
sort((a, b) => `${scopeOrder(a.scope_type)}:${a.name}`.localeCompare(`${scopeOrder(b.scope_type)}:${b.name}`));
}
export function scopeOrder(scopeType: MailProfileScope): number {
if (scopeType === "system") return 0;
if (scopeType === "tenant") return 1;
if (scopeType === "user" || scopeType === "group") return 2;
return 3;
}
function policyHelp(description: string): ReactNode {
return <span>{description}</span>;
}
function credentialSelectionLabel(inherit: boolean | null | undefined): string {
return inherit === false ? "i18n:govoplan-mail.credential_policy_explicit" : "i18n:govoplan-mail.credential_policy_profile";
}
function mailCredentialPolicyPathLines(key: "smtp_credentials" | "imap_credentials", items: NormalizedPolicySourcePathItem[]): string[] {
const lines: string[] = [];
for (const [index, item] of items.entries()) {
const policy = policySourceRecord(item);
const value = asRecord(policy[key]).inherit;
const label = typeof value === "boolean" ? credentialSelectionLabel(value) : "i18n:govoplan-mail.credential_policy_parent";
const locked = asRecord(policy.allow_lower_level_limits)[`${key}.inherit`] === false;
lines.push(i18nMessage(locked ? "i18n:govoplan-mail.credential_policy_path_locked" : "i18n:govoplan-mail.credential_policy_path", { value0: `${policyPathPrefix(index)}${item.label}`, value1: label }));
if (locked) break;
}
return lines;
}
function mailBooleanPolicyPathHelp(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", sources: PolicySourcePathItem[]): ReactNode {
return <PolicyPathHelp lines={mailBooleanPolicyPathLines(key, normalizePolicySourcePathItems(sources))} />;
}
function mailBooleanPolicyPathLines(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", items: NormalizedPolicySourcePathItem[]): string[] {
if (items.length === 0) return ["i18n:govoplan-mail.system_allow.ed6744b1"];
const lines: string[] = [];
for (const [index, item] of items.entries()) {
const policy = policySourceRecord(item);
const rawValue = policy[key];
const value = rawValue === true ? "i18n:govoplan-mail.allow.3ad0e369" : rawValue === false ? "i18n:govoplan-mail.deny.53577bb5" : "i18n:govoplan-mail.inherit.18f99833";
const lowerLocked = asRecord(policy.allow_lower_level_limits)[key] === false;
const stops = rawValue === false || lowerLocked;
lines.push(`${policyPathPrefix(index)}${item.label}: ${stops ? `${value} without override` : value}`);
if (stops) break;
}
return lines;
}
function policySourceRecord(item: NormalizedPolicySourcePathItem): Record<string, unknown> {
return asRecord(item.policy);
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function policyPathPrefix(index: number): string {
return index === 0 ? "" : `${" ".repeat(index - 1)}> `;
}
function effectiveBooleanLabel(value: boolean | null | undefined, policy: MailProfilePolicy | null): string {
if (!policy) return "i18n:govoplan-mail.loading.b04ba49f";
return value ? "i18n:govoplan-mail.allowed.77c7b490" : "i18n:govoplan-mail.blocked.99613c74";
}
function patternPolicyNote(key: MailProfilePatternKey): string {
if (key === "smtp_hosts") return "i18n:govoplan-mail.smtp_server_host_patterns.cf6120c3";
if (key === "imap_hosts") return "i18n:govoplan-mail.imap_server_host_patterns.52b20b83";
if (key === "envelope_senders") return "i18n:govoplan-mail.smtp_envelope_sender_patterns.8c1fd95e";
if (key === "from_headers") return "i18n:govoplan-mail.visible_from_header_patterns.ea77d99d";
return "i18n:govoplan-mail.recipient_domain_patterns.68466f5b";
}
function booleanToFlag(value: boolean | null | undefined): PolicyFlagValue {
if (value === true) return "allow";
if (value === false) return "deny";
return "inherit";
}
function flagToBoolean(value: PolicyFlagValue): boolean | null {
if (value === "allow") return true;
if (value === "deny") return false;
return null;
}
function parsePatternList(value: string): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const item of value.split(/[\n,]+/)) {
const pattern = item.trim();
if (pattern && !seen.has(pattern)) {
seen.add(pattern);
result.push(pattern);
}
}
return result;
}
function patternsToText(value: string[] | undefined): string {
return (value ?? []).join("\n");
}
function mailPolicySourcePath(scopeType: MailProfileScope): string[] {
if (scopeType === "system") return ["i18n:govoplan-mail.system.bc0792d8"];
if (scopeType === "tenant") return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78"];
if (scopeType === "user") return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.user.9f8a2389"];
if (scopeType === "group") return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.group.171a0606"];
return ["i18n:govoplan-mail.system.bc0792d8", "i18n:govoplan-mail.tenant.3ca93c78", "i18n:govoplan-mail.owner_policy.1e8df143", "i18n:govoplan-mail.campaign.69390e16"];
}
export function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | MailJmapTransportSettings | null | undefined): string {
if (!transport) return "i18n:govoplan-mail.not_configured.811931bb";
if ("session_url" in transport) return transport.session_url || "No Session URL";
const host = transport.host || "i18n:govoplan-mail.no_host.4c710d7d";
const port = transport.port ? `:${transport.port}` : "";
return `${host}${port}`;
}
export function scopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "system") return "i18n:govoplan-mail.system.bc0792d8";
if (profile.scope_type === "tenant") return "i18n:govoplan-mail.tenant.3ca93c78";
if (profile.scope_type === "user") return "i18n:govoplan-mail.user.9f8a2389";
if (profile.scope_type === "group") return "i18n:govoplan-mail.group.171a0606";
return "i18n:govoplan-mail.campaign.69390e16";
}
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+289 -120
View File
@@ -1,17 +1,22 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Activity, Check, ChevronRight, Database, Home, Mail, MailOpen, Paperclip, RefreshCw, Search, UserPlus, X } from "lucide-react";
import { useLocation } from "react-router";
import { ToolbarGroup, ActionToolbar,
import { ActionToolbar,
ActionBlockerHint,
Button,
CountBadge,
DataGridPaginationBar,
DocumentationHelpLink,
Dialog,
DismissibleAlert,
ExplorerTree,
FormField,
FormSection,
IconButton,
LoadingIndicator,
MessageDisplayPanel,
WorkspaceActionBar,
WorkspaceFrame,
hasAnyScope,
formatDateTime,
i18nMessage,
@@ -25,6 +30,7 @@ import {
getMailboxMessage,
listMailAddressWriteTargets,
listMailboxMessages,
listMailboxFolders,
listMailServerProfiles,
type MailAddressWriteTarget,
type MailImapFolderResponse,
@@ -43,6 +49,11 @@ const MAILBOX_DOCUMENTATION = {
documentationType: "user"
} as const;
// Each context read revalidates the current principal and explicit Reload must
// not reuse Core's short-lived GET response/in-flight promise cache. Mail's
// bounded provider index remains available unless the refresh flag is set.
const MAILBOX_READ_OPTIONS = { cache: "no-store" } as const;
export default function MailboxPage({ settings, auth }: { settings: ApiSettings; auth: AuthInfo }) {
const navigate = useGuardedNavigate();
const location = useLocation();
@@ -50,6 +61,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const [selectedProfileId, setSelectedProfileId] = useState("");
const [folders, setFolders] = useState<MailImapFolderResponse[]>([]);
const [selectedFolder, setSelectedFolder] = useState("INBOX");
const [selectedFolderGroup, setSelectedFolderGroup] = useState<Pick<MailFolderNode, "id" | "label"> | null>(null);
const [foldersLoadedForProfile, setFoldersLoadedForProfile] = useState("");
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(() => new Set());
const [messages, setMessages] = useState<MailMailboxMessageSummary[]>([]);
@@ -69,10 +81,21 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const [folderError, setFolderError] = useState("");
const [messageError, setMessageError] = useState("");
const [detailError, setDetailError] = useState("");
const [mailToolsOpen, setMailToolsOpen] = useState(false);
const [reloadingMailbox, setReloadingMailbox] = useState(false);
const profileRequestRef = useRef(0);
const mailboxReloadRequestRef = useRef(0);
const selectedProfileIdRef = useRef(selectedProfileId);
selectedProfileIdRef.current = selectedProfileId;
const authorityKey = JSON.stringify([settings.apiBaseUrl, settings.apiKey, settings.accessToken, auth.user.id, auth.active_tenant?.id ?? auth.tenant.id]);
const authorityRef = useRef(authorityKey);
authorityRef.current = authorityKey;
const revealedFolderRef = useRef("");
const selectedMessageKeyRef = useRef("");
const folderRequestRef = useRef(0);
const messageListRequestRef = useRef(0);
const messageDetailRequestRef = useRef(0);
const loadedMessagePageRef = useRef<{ profileId: string; folder: string; page: number; pageSize: number } | null>(null);
const mailboxPageCursorsRef = useRef<Record<string, string | null>>({});
const skipNextMessageLoadRef = useRef(false);
const launchRequestRef = useRef<MailboxLaunch | null>(parseMailboxLaunch(location.search));
@@ -81,34 +104,46 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
const mailboxProfiles = useMemo(() => profiles.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile)), [profiles]);
const selectedMailboxProtocol = mailboxProtocolForProfile(selectedProfile) ?? "imap";
const folderTree = useMemo(() => buildMailboxFolderTree(folders), [folders]);
const selectedFolderNodeId = useMemo(() => findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder]);
const selectedFolderNodeId = useMemo(() => selectedFolderGroup?.id ?? findFolderNodeId(folderTree, selectedFolder) ?? "", [folderTree, selectedFolder, selectedFolderGroup]);
const filteredMessages = useMemo(() => filterMessages(messages, messageQuery), [messageQuery, messages]);
const messagePageCount = Math.max(1, Math.ceil((messageTotalCount ?? 0) / messagePageSize));
const shellBusy = loadingProfiles || loadingFolders || loadingMessages;
const shellBusy = loadingProfiles || loadingFolders || loadingMessages || reloadingMailbox;
const noMailboxProfiles = !loadingProfiles && mailboxProfiles.length === 0;
const foldersReady = Boolean(selectedProfileId) && foldersLoadedForProfile === selectedProfileId;
const selectedMessageKey = pendingMessageKey || selectedMessageKeyState || (selectedMessage ? mailboxMessageKey(selectedMessage.folder || selectedFolder, selectedMessage.uid) : "");
const messageCountLabel = messageListCountLabel(messages.length, messageTotalCount, loadingMessages, foldersReady);
const syncState = mailboxSyncState(messageProvenance);
const folderEmptyText = folderError || (noMailboxProfiles ? "No IMAP- or JMAP-enabled Mail profiles are available." : loadingFolders ? "i18n:govoplan-mail.loading_folders.17f9f0e2" : "i18n:govoplan-mail.no_folders_available.14133b26");
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 || (selectedFolderGroup ? "i18n:govoplan-mail.folder_group_selection" : !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 profileReloadBlocker = shellBusy ? "i18n:govoplan-mail.mailbox_refresh_in_progress" : "";
const folderReloadBlocker = !selectedProfileId
? "Select an IMAP- or JMAP-enabled Mail profile before refreshing folders."
: loadingFolders || loadingMessages
: shellBusy
? "Wait for the current mailbox refresh to finish."
: "";
const messageReloadBlocker = !selectedProfileId
? "Select an IMAP- or JMAP-enabled Mail profile before refreshing messages."
: !selectedFolder || !foldersReady
? "Select a loaded mailbox folder before refreshing messages."
: loadingMessages
: shellBusy
? "Messages are already loading."
: "";
useEffect(() => {void loadProfiles();}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
selectProfile("");
setProfiles([]);
setMailToolsOpen(false);
void loadProfiles();
return () => {
profileRequestRef.current += 1;
folderRequestRef.current += 1;
messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1;
mailboxReloadRequestRef.current += 1;
};
}, [authorityKey]);
useEffect(() => {
const request = parseMailboxLaunch(location.search);
launchRequestRef.current = request;
@@ -137,13 +172,17 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}, [foldersReady, messagePage, messagePageSize, selectedProfileId, selectedFolder]);
useEffect(() => {
if (selectedMailboxProtocol !== "jmap" || !selectedProfileId || !selectedFolder || !foldersReady) return;
const requestAuthority = authorityKey;
const profileId = selectedProfileId;
const listRequest = messageListRequestRef.current;
const handle = window.setTimeout(() => {
if (requestAuthority !== authorityRef.current || profileId !== selectedProfileIdRef.current || listRequest !== messageListRequestRef.current) return;
setMessagePage(1);
mailboxPageCursorsRef.current = {};
void loadMessages(selectedProfileId, selectedFolder, 1, messagePageSize);
}, 250);
return () => window.clearTimeout(handle);
}, [messageQuery]);
}, [messageQuery, selectedFolderGroup]);
useEffect(() => {
const request = launchRequestRef.current;
@@ -160,10 +199,12 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
useEffect(() => {
const handlePreviewShortcut = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
if (mailToolsOpen || isEditableTarget(event.target)) return;
if (event.key === "Escape") {
if (selectedMessage || selectedMessageKey || pendingMessageKey || detailError) {
event.preventDefault();
messageDetailRequestRef.current += 1;
setLoadingMessage(false);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
@@ -187,55 +228,86 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
window.addEventListener("keydown", handlePreviewShortcut);
return () => window.removeEventListener("keydown", handlePreviewShortcut);
}, [detailError, filteredMessages, loadingMessage, pendingMessageKey, selectedFolder, selectedMessage, selectedMessageKey, shellBusy]);
}, [detailError, filteredMessages, loadingMessage, mailToolsOpen, pendingMessageKey, selectedFolder, selectedMessage, selectedMessageKey, shellBusy]);
useEffect(() => {
const revealKey = `${selectedProfileId}:${selectedFolder}`;
if (revealedFolderRef.current === revealKey) return;
const ancestorIds = folderAncestorIds(folderTree, selectedFolder);
if (ancestorIds.length === 0) return;
revealedFolderRef.current = revealKey;
setExpandedFolders((current) => {
const next = new Set(current);
ancestorIds.forEach((id) => next.add(id));
return next;
});
}, [folderTree, selectedFolder]);
}, [folderTree, selectedFolder, selectedProfileId]);
async function loadProfiles() {
const requestId = ++profileRequestRef.current;
const requestAuthority = authorityRef.current;
setLoadingProfiles(true);
setError("");
try {
const loaded = await listMailServerProfiles(settings);
const loaded = await listMailServerProfiles(settings, false, undefined, MAILBOX_READ_OPTIONS);
if (requestId !== profileRequestRef.current || requestAuthority !== authorityRef.current) return null;
const usable = loaded.filter((profile) => profile.is_active && mailboxProtocolForProfile(profile));
const requestedProfileId = parseMailboxLaunch(location.search).profileId;
setProfiles(loaded);
setSelectedProfileId((current) =>
requestedProfileId && usable.some((profile) => profile.id === requestedProfileId)
? requestedProfileId
: current && usable.some((profile) => profile.id === current)
? current
: usable[0]?.id ?? ""
);
if (usable.length === 0) {
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
mailboxPageCursorsRef.current = {};
skipNextMessageLoadRef.current = false;
}
const currentId = selectedProfileIdRef.current;
const next = usable.find((profile) => profile.id === currentId)
?? usable.find((profile) => profile.id === requestedProfileId)
?? usable[0] ?? null;
const changed = (next?.id ?? "") !== currentId;
if (changed) selectProfile(next?.id ?? "");
return { selected: next, changed };
} catch (err) {
if (requestId !== profileRequestRef.current || requestAuthority !== authorityRef.current) return null;
setError(errorText(err));
return null;
} finally {
setLoadingProfiles(false);
if (requestId === profileRequestRef.current && requestAuthority === authorityRef.current) setLoadingProfiles(false);
}
}
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false) {
async function reloadMailbox() {
const requestId = ++mailboxReloadRequestRef.current;
setReloadingMailbox(true);
try {
const result = await loadProfiles();
if (!result || requestId !== mailboxReloadRequestRef.current || result.changed || !result.selected) return;
if (selectedFolderGroup) await refreshFolderCatalogue(result.selected);
else await loadMailboxBootstrap(result.selected.id, true, result.selected);
} finally {
if (requestId === mailboxReloadRequestRef.current) setReloadingMailbox(false);
}
}
async function refreshFolderCatalogue(profile = selectedProfile) {
if (!profile) return;
const requestId = ++folderRequestRef.current;
const requestAuthority = authorityRef.current;
setLoadingFolders(true);
setFolderError("");
setError("");
try {
const response = await listMailboxFolders(settings, profile.id, true, true, mailboxProtocolForProfile(profile) ?? "imap", MAILBOX_READ_OPTIONS);
if (requestId !== folderRequestRef.current || requestAuthority !== authorityRef.current || profile.id !== selectedProfileIdRef.current) return;
if (!response.ok) throw new Error(response.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
setFolders(response.folders ?? []);
setFoldersLoadedForProfile(profile.id);
} catch (err) {
if (requestId !== folderRequestRef.current || requestAuthority !== authorityRef.current || profile.id !== selectedProfileIdRef.current) return;
setFolderError(errorText(err));
setError(errorText(err));
} finally {
if (requestId === folderRequestRef.current && requestAuthority === authorityRef.current) setLoadingFolders(false);
}
}
async function loadMailboxBootstrap(profileId = selectedProfileId, refresh = false, profileOverride?: MailServerProfile) {
if (!profileId) return;
const targetProfile = profiles.find((profile) => profile.id === profileId) ?? null;
const targetProfile = profileOverride ?? profiles.find((profile) => profile.id === profileId) ?? null;
const mailboxProtocol = mailboxProtocolForProfile(targetProfile) ?? "imap";
const profileInbox = mailboxProtocol === "imap" ? targetProfile?.imap?.folder_mappings?.inbox || "" : "";
const requestedLaunch = launchRequestRef.current;
@@ -247,28 +319,38 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
: profileInbox || "INBOX");
const folderRequestId = ++folderRequestRef.current;
const messageRequestId = ++messageListRequestRef.current;
messageDetailRequestRef.current += 1;
const requestAuthority = authorityRef.current;
const isCurrent = () => folderRequestId === folderRequestRef.current
&& messageRequestId === messageListRequestRef.current
&& requestAuthority === authorityRef.current && profileId === selectedProfileIdRef.current;
const preserve = refresh && foldersLoadedForProfile === profileId;
// A refreshed JMAP query starts a new cursor chain; IMAP offsets can retain
// the current page without silently losing the user's mailbox context.
const requestedPage = preserve && mailboxProtocol !== "jmap" ? messagePage : 1;
const rememberedMessageKey = preserve ? selectedMessageKeyRef.current : "";
const detailRequestId = ++messageDetailRequestRef.current;
setLoadingMessage(false);
setPendingMessageKey("");
setLoadingFolders(true);
setLoadingMessages(true);
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
if (!preserve) {
setFoldersLoadedForProfile("");
setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
}
setFolderError("");
setMessageError("");
setDetailError("");
setError("");
try {
const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, 0, refresh, mailboxProtocol);
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
const response = await bootstrapMailbox(settings, profileId, requestedFolder, messagePageSize, (requestedPage - 1) * messagePageSize, refresh, mailboxProtocol, MAILBOX_READ_OPTIONS);
if (!isCurrent()) return;
if (!response.folders.ok) throw new Error(response.folders.message || "i18n:govoplan-mail.mailbox_folders_could_not_be_loaded.c3e3880e");
const loadedFolders = response.folders.folders?.length ? response.folders.folders : [{ name: "INBOX", flags: [] }];
const loadedMessages = response.messages.messages ?? [];
const total = response.messages.total_count ?? loadedMessages.length;
let nextFolder = response.folder || response.messages.folder || selectedFolder || "INBOX";
if (!loadedFolders.some((folder) => folder.name === nextFolder)) {
const detectedInbox = response.folders.detected_folder_mappings?.inbox;
@@ -276,39 +358,67 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
? detectedInbox
: loadedFolders.some((folder) => folder.name === "INBOX") ? "INBOX" : response.folders.detected_sent_folder || loadedFolders[0]?.name || "INBOX";
}
let messageResponse = mailboxProtocol === "jmap" && messageQuery.trim()
? await listMailboxMessages(settings, profileId, nextFolder, messagePageSize, 0, null, refresh, mailboxProtocol, messageQuery, MAILBOX_READ_OPTIONS)
: response.messages;
if (!isCurrent()) return;
const total = messageResponse.total_count ?? messageResponse.messages?.length ?? 0;
const nextPage = Math.min(requestedPage, Math.max(1, Math.ceil(total / messagePageSize)));
if (nextPage !== requestedPage) {
messageResponse = await listMailboxMessages(settings, profileId, nextFolder, messagePageSize, (nextPage - 1) * messagePageSize, null, refresh, mailboxProtocol, null, MAILBOX_READ_OPTIONS);
if (!isCurrent()) return;
}
const loadedMessages = messageResponse.messages ?? [];
const foldersWithCounts = loadedFolders.map((folder) => folder.name === nextFolder ? { ...folder, message_count: total } : folder);
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize, "");
const cursorKey = mailboxCursorKey(profileId, nextFolder, messagePageSize, mailboxProtocol === "jmap" ? messageQuery : "");
mailboxPageCursorsRef.current = {
[`${cursorKey}:1`]: null,
[`${cursorKey}:2`]: response.messages.next_cursor ?? null
[`${cursorKey}:${nextPage}`]: null,
[`${cursorKey}:${nextPage + 1}`]: messageResponse.next_cursor ?? null
};
skipNextMessageLoadRef.current = foldersLoadedForProfile !== profileId || nextFolder !== selectedFolder || messagePage !== 1;
skipNextMessageLoadRef.current = foldersLoadedForProfile !== profileId || nextFolder !== selectedFolder || messagePage !== nextPage;
loadedMessagePageRef.current = { profileId, folder: nextFolder, page: nextPage, pageSize: messagePageSize };
setFolders(foldersWithCounts);
setExpandedFolders(new Set());
if (!preserve) setExpandedFolders(new Set());
setSelectedFolderGroup(null);
setSelectedFolder(nextFolder);
setMessagePage(nextPage);
setFoldersLoadedForProfile(profileId);
setMessages(loadedMessages);
setMessageTotalCount(total);
setMessageProvenance(mailboxProvenance(response.messages));
setMessageProvenance(mailboxProvenance(messageResponse));
const retainedMessage = loadedMessages.find((message) => mailboxMessageKey(message.folder || nextFolder, message.uid) === rememberedMessageKey);
// Explicit preview navigation or Escape during a refresh wins over the
// remembered selection; completing a read must not undo the user's action.
if (detailRequestId === messageDetailRequestRef.current && selectedMessageKeyRef.current === rememberedMessageKey) {
if (retainedMessage) await openMessage(retainedMessage, profileId, mailboxProtocol, true);
else if (rememberedMessageKey) {
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
}
}
} catch (err) {
if (folderRequestId !== folderRequestRef.current || messageRequestId !== messageListRequestRef.current) return;
if (!isCurrent()) return;
const message = errorText(err);
skipNextMessageLoadRef.current = false;
setFolderError(message);
setMessageError(message);
setError(message);
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
if (!preserve) {
loadedMessagePageRef.current = null;
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
}
} finally {
if (folderRequestId === folderRequestRef.current) setLoadingFolders(false);
if (messageRequestId === messageListRequestRef.current) setLoadingMessages(false);
if (folderRequestId === folderRequestRef.current && requestAuthority === authorityRef.current) setLoadingFolders(false);
if (messageRequestId === messageListRequestRef.current && requestAuthority === authorityRef.current) setLoadingMessages(false);
}
}
@@ -316,11 +426,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
if (!profileId || !folder) return;
const mailboxProtocol = mailboxProtocolForProfile(profiles.find((profile) => profile.id === profileId) ?? null) ?? "imap";
const requestId = ++messageListRequestRef.current;
const requestAuthority = authorityRef.current;
const isCurrent = () => requestId === messageListRequestRef.current && requestAuthority === authorityRef.current && profileId === selectedProfileIdRef.current;
const offset = (Math.max(1, page) - 1) * pageSize;
const cursorKey = mailboxCursorKey(profileId, folder, pageSize, mailboxProtocol === "jmap" ? messageQuery : "");
const cursor = page <= 1 ? null : mailboxPageCursorsRef.current[`${cursorKey}:${page}`] || null;
setLoadingMessages(true);
setMessageProvenance(null);
if (!refresh) setMessageProvenance(null);
setMessageError("");
setDetailError("");
setError("");
@@ -334,11 +446,13 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
cursor,
refresh,
mailboxProtocol,
mailboxProtocol === "jmap" ? messageQuery : null
mailboxProtocol === "jmap" ? messageQuery : null,
MAILBOX_READ_OPTIONS
);
if (requestId !== messageListRequestRef.current) return;
if (!isCurrent()) return;
const loaded = response.messages ?? [];
const total = response.total_count ?? loaded.length;
loadedMessagePageRef.current = { profileId, folder, page, pageSize };
if (page <= 1) mailboxPageCursorsRef.current[`${cursorKey}:1`] = null;
mailboxPageCursorsRef.current[`${cursorKey}:${page + 1}`] = response.next_cursor ?? null;
setMessages(loaded);
@@ -347,66 +461,87 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
setFolderMessageCount(folder, total);
const rememberedKey = selectedMessageKeyRef.current;
if (rememberedKey && !loaded.some((message) => mailboxMessageKey(message.folder || folder, message.uid) === rememberedKey)) {
messageDetailRequestRef.current += 1;
setLoadingMessage(false);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
selectedMessageKeyRef.current = "";
}
} catch (err) {
if (requestId !== messageListRequestRef.current) return;
if (!isCurrent()) return;
const message = errorText(err);
setMessageError(message);
setError(message);
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedMessage(null);
setSelectedMessageKeyState("");
setPendingMessageKey("");
const committed = loadedMessagePageRef.current;
if (committed?.profileId === profileId && committed.folder === folder && (committed.page !== page || committed.pageSize !== pageSize)) {
// Retained rows must keep their actual page/size labels after a failed
// pagination read; restoring controls is not a second provider request.
skipNextMessageLoadRef.current = true;
setMessagePage(committed.page);
setMessagePageSize(committed.pageSize);
}
// A failed read is not an empty mailbox. Keep the last loaded index and
// selection visible until a successful refresh or an explicit switch.
} finally {
if (requestId === messageListRequestRef.current) setLoadingMessages(false);
if (isCurrent()) setLoadingMessages(false);
}
}
async function openMessage(message: MailMailboxMessageSummary) {
if (!selectedProfileId) return;
async function openMessage(message: MailMailboxMessageSummary, profileId = selectedProfileId, protocol = selectedMailboxProtocol, preservePreview = false) {
if (!profileId) return;
const folderName = message.folder || selectedFolder;
const requestId = ++messageDetailRequestRef.current;
const requestAuthority = authorityRef.current;
const isCurrent = () => requestId === messageDetailRequestRef.current && requestAuthority === authorityRef.current && profileId === selectedProfileIdRef.current;
const nextKey = mailboxMessageKey(folderName, message.uid);
setSelectedMessageKeyState(nextKey);
selectedMessageKeyRef.current = nextKey;
setPendingMessageKey(nextKey);
setSelectedMessage(null);
if (!preservePreview) setSelectedMessage(null);
setDetailError("");
setLoadingMessage(true);
setError("");
try {
const response = await getMailboxMessage(settings, selectedProfileId, folderName, message.uid, selectedMailboxProtocol);
if (requestId !== messageDetailRequestRef.current) return;
const response = await getMailboxMessage(settings, profileId, folderName, message.uid, protocol, MAILBOX_READ_OPTIONS);
if (!isCurrent()) return;
setSelectedMessage(response.message);
setSelectedMessageKeyState(mailboxMessageKey(response.message.folder || folderName, response.message.uid));
setPendingMessageKey("");
} catch (err) {
if (requestId !== messageDetailRequestRef.current) return;
if (!isCurrent()) return;
const messageText = errorText(err);
setDetailError(messageText);
setError(messageText);
setPendingMessageKey("");
} finally {
if (requestId === messageDetailRequestRef.current) setLoadingMessage(false);
if (isCurrent()) setLoadingMessage(false);
}
}
function selectProfile(profileId: string) {
loadedMessagePageRef.current = null;
mailboxReloadRequestRef.current += 1;
folderRequestRef.current += 1;
messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1;
setSelectedProfileId(profileId);
selectedProfileIdRef.current = profileId;
setReloadingMailbox(false);
setSelectedFolderGroup(null);
setFolders([]);
setFoldersLoadedForProfile("");
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setMessagePage(1);
setSelectedFolder("INBOX");
setMessageQuery("");
setError("");
setFolderError("");
setMessageError("");
setDetailError("");
revealedFolderRef.current = "";
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
@@ -421,8 +556,16 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
function openFolderNode(node: MailFolderNode) {
if (node.folderName) {
setSelectedFolderGroup(null);
if (node.folderName === selectedFolder && foldersReady) void loadMessages(selectedProfileId, node.folderName, messagePage, messagePageSize);else
{
loadedMessagePageRef.current = null;
messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1;
setLoadingMessage(false);
setMessages([]);
setMessageTotalCount(null);
setMessageProvenance(null);
setSelectedFolder(node.folderName);
setMessagePage(1);
setSelectedMessage(null);
@@ -433,7 +576,26 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}
return;
}
toggleFolderNode(node);
// Synthetic parent folders have no provider mailbox to open. Selecting
// their label must not toggle expansion or fetch an invented folder name.
loadedMessagePageRef.current = null;
messageListRequestRef.current += 1;
messageDetailRequestRef.current += 1;
setSelectedFolderGroup({ id: node.id, label: node.label });
setSelectedFolder("");
setMessages([]);
setMessageTotalCount(0);
setMessageProvenance(null);
setMessagePage(1);
setSelectedMessage(null);
setSelectedMessageKeyState("");
selectedMessageKeyRef.current = "";
setPendingMessageKey("");
setLoadingMessage(false);
setLoadingMessages(false);
setMessageError("");
setDetailError("");
skipNextMessageLoadRef.current = false;
}
function toggleFolderNode(node: MailFolderNode) {
@@ -460,9 +622,34 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
}
return (
<div className="workspace-data-page module-entry-page file-manager-page file-manager-fullscreen mailbox-page">
<WorkspaceFrame as="main" height="viewport" surface="plain" label="i18n:govoplan-mail.mailbox_workspace" className="mailbox-page" helpContextId="mail.mailbox" helpModuleId="mail" helpTopicId="mail.workflow.read-mailbox">
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
<WorkspaceActionBar
variant="workspace"
scope="workspace"
label="i18n:govoplan-mail.mail_actions.c08b5f08"
refreshable
reloadAction={{
onReload: () => void reloadMailbox(),
loading: shellBusy,
state: error ? "reload-failed" : "current",
helpContextId: "mail.mailbox.reload",
helpModuleId: "mail",
helpTopicId: "mail.workflow.read-mailbox"
}}
contextActions={<>
<FormField label="i18n:govoplan-mail.mailbox_profile" className="mailbox-profile-field">
<select value={selectedProfileId} disabled={loadingProfiles || mailboxProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{mailboxProfiles.length === 0 && <option value="">i18n:govoplan-mail.no_mailbox_profiles</option>}
{mailboxProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</FormField>
<Button type="button" onClick={() => setMailToolsOpen(true)} helpContextId="mail.mailbox.tools" helpModuleId="mail" helpTopicId="mail.workflow.read-mailbox">i18n:govoplan-mail.mailbox_tools</Button>
</>}
helpAction={<DocumentationHelpLink reference={MAILBOX_DOCUMENTATION} />}
/>
<div className={`file-manager-shell mailbox-shell ${shellBusy ? "is-loading" : ""}`}>
<aside className="file-tree-panel" aria-label="i18n:govoplan-mail.mailbox_folders.c92f6de4">
<div className="file-tree-heading">i18n:govoplan-mail.folders.19adc47b</div>
@@ -497,37 +684,6 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<section className="file-list-panel mailbox-message-list-panel" aria-label="i18n:govoplan-mail.mailbox_messages.5c06afaf">
<div className="file-list-sticky">
<ActionToolbar justify="between" className="file-manager-toolbar mailbox-toolbar" aria-label="i18n:govoplan-mail.mail_actions.c08b5f08">
<label className="mailbox-profile-field">
<span>Mailbox profile</span>
<select value={selectedProfileId} disabled={loadingProfiles || loadingFolders || mailboxProfiles.length === 0} onChange={(event) => selectProfile(event.target.value)}>
{mailboxProfiles.length === 0 && <option value="">No mailbox profiles available</option>}
{mailboxProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name}</option>)}
</select>
</label>
<ToolbarGroup grow className="mailbox-toolbar-meta">{selectedProfile ? transportLabel(selectedProfile) : "No mailbox profile selected"}</ToolbarGroup>
<ToolbarGroup align="end" 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={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={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={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>
</ToolbarGroup>
</ActionToolbar>
{noMailboxProfiles &&
<ActionBlockerHint
className="mailbox-profile-blocker"
@@ -543,7 +699,7 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
<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>
<span className="file-breadcrumb-segment"><ChevronRight size={14} aria-hidden="true" /><span className="file-breadcrumb mailbox-breadcrumb-static">{selectedFolderGroup?.label ?? selectedFolder}</span></span>
</nav>
<div className="mailbox-filter-row">
@@ -667,7 +823,20 @@ export default function MailboxPage({ settings, auth }: { settings: ApiSettings;
</div>
}
</div>
</div>);
<Dialog open={mailToolsOpen} title="i18n:govoplan-mail.mailbox_tools" description={selectedProfile ? `${selectedProfile.name} · ${transportLabel(selectedProfile)}` : "i18n:govoplan-mail.no_mailbox_profiles"} onClose={() => setMailToolsOpen(false)}
footer={<Button type="button" onClick={() => setMailToolsOpen(false)}>i18n:govoplan-core.close.bbfa773e</Button>}>
<FormSection title="i18n:govoplan-mail.mailbox_refresh_tools" description="i18n:govoplan-mail.mailbox_refresh_tools_help">
<ActionToolbar surface="plain">
<Button type="button" onClick={() => { setMailToolsOpen(false); void loadProfiles(); }} disabledReason={profileReloadBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-mail.refresh_mailbox_profiles</Button>
<Button type="button" onClick={() => { setMailToolsOpen(false); void refreshFolderCatalogue(); }} disabledReason={folderReloadBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-mail.refresh_mailbox_folder_catalogue</Button>
<Button type="button" onClick={() => { setMailToolsOpen(false); void loadMessages(selectedProfileId, selectedFolder, messagePage, messagePageSize, true); }} disabledReason={messageReloadBlocker}><RefreshCw size={16} aria-hidden="true" /> i18n:govoplan-mail.refresh_mailbox_message_index</Button>
</ActionToolbar>
</FormSection>
<FormSection variant="separated" title="i18n:govoplan-mail.mailbox_related_tools">
<ActionToolbar surface="plain"><Button type="button" onClick={() => { setMailToolsOpen(false); navigate("/mail/bounces"); }} disabledReason={!hasAnyScope(auth, ["mail:bounce:read", "mail:bounce:manage"]) ? "i18n:govoplan-mail.mailbox_bounce_permission" : ""}><Activity size={16} aria-hidden="true" /> i18n:govoplan-mail.mailbox_bounce_status</Button></ActionToolbar>
</FormSection>
</Dialog>
</WorkspaceFrame>);
}
@@ -889,7 +1058,7 @@ function transportLabel(profile: MailServerProfile): string {
return `${imap.host}:${imap.port ?? "?"} ${imap.security ?? ""}`.trim();
}
function preferredJmapServer(profile: MailServerProfile | null): MailServerProfile["servers"][number] | null {
function preferredJmapServer(profile: MailServerProfile | null): NonNullable<MailServerProfile["servers"]>[number] | null {
if (!profile) return null;
const servers = (profile.servers ?? []).filter((server) => server.protocol === "jmap" && server.is_active);
return servers.find((server) => server.is_default) ?? servers[0] ?? null;
@@ -4,6 +4,7 @@ import {
type ApiSettings,
type CredentialReferenceSelectorContext,
type CredentialReferenceSelectorsUiCapability,
type DeltaDeletedItem,
type ReferenceOption,
type ReferenceOptionProvider
} from "@govoplan/core-webui";
@@ -24,7 +25,11 @@ export function createMailServerReferenceProvider(
let cataloguePromise: Promise<ReferenceOption[]> | null = null;
async function catalogue(signal: AbortSignal): Promise<ReferenceOption[]> {
cataloguePromise ??= loadProfiles(settings, context, signal)
if (signal.aborted) throw abortError();
// The catalogue belongs to this provider, not to its first subscriber.
// StrictMode, closing an editor, or a new search can cancel one caller
// while another is still awaiting the same authorized metadata request.
cataloguePromise ??= loadProfiles(settings, context)
.then(mailServerOptions)
.catch((error: unknown) => {
cataloguePromise = null;
@@ -95,8 +100,7 @@ function mailServerOptions(
async function loadProfiles(
settings: ApiSettings,
context: CredentialReferenceSelectorContext,
signal: AbortSignal
context: CredentialReferenceSelectorContext
): Promise<MailServerProfile[]> {
let watermark: string | null = null;
let profiles: MailServerProfile[] = [];
@@ -109,7 +113,6 @@ async function loadProfiles(
since: first ? null : watermark,
limit: 200
});
if (signal.aborted) throw abortError();
profiles = response.full
? response.profiles
: mergeProfiles(profiles, response.profiles, response.deleted);
@@ -123,12 +126,12 @@ async function loadProfiles(
function mergeProfiles(
current: readonly MailServerProfile[],
changed: readonly MailServerProfile[],
deleted: readonly { resource_type: string; resource_id: string }[]
deleted: readonly DeltaDeletedItem[]
): MailServerProfile[] {
const removed = new Set(
deleted
.filter((item) => item.resource_type === "mail_profile")
.map((item) => item.resource_id)
.map((item) => item.id)
);
const merged = new Map(
current
+52 -2
View File
@@ -2,6 +2,31 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-mail.mailbox_workspace": "Mailbox workspace",
"i18n:govoplan-mail.mailbox_refresh_in_progress": "Wait for the current mailbox refresh to finish.",
"i18n:govoplan-mail.mailbox_profile": "Mailbox profile",
"i18n:govoplan-mail.no_mailbox_profiles": "No mailbox profiles available",
"i18n:govoplan-mail.mailbox_tools": "Mailbox tools",
"i18n:govoplan-mail.mailbox_refresh_tools": "Refresh a specific part",
"i18n:govoplan-mail.mailbox_refresh_tools_help": "The page Reload refreshes the current mailbox context. Use these targeted reads only when you need to refresh account availability, the folder catalogue, or the current message page separately. No read/unread flags or other provider state are changed.",
"i18n:govoplan-mail.refresh_mailbox_profiles": "Refresh available profiles",
"i18n:govoplan-mail.refresh_mailbox_folder_catalogue": "Refresh folders only",
"i18n:govoplan-mail.refresh_mailbox_message_index": "Refresh messages only",
"i18n:govoplan-mail.mailbox_related_tools": "Related Mail tools",
"i18n:govoplan-mail.mailbox_bounce_status": "Bounce status",
"i18n:govoplan-mail.mailbox_bounce_permission": "Permission to read or manage Mail bounce processing is required.",
"i18n:govoplan-mail.folder_group_selection": "This grouping contains mailbox folders. Use its folder icon to expand it, then select a mailbox folder to view messages.",
"i18n:govoplan-mail.credential_selection_policy": "Credential selection",
"i18n:govoplan-mail.credential_selection_policy_help": "Allow the selected Mail server's default credential, or require the campaign to explicitly select an authorized Mail-owned credential. Both choices keep secrets in Mail; neither permits campaign-local passwords. Inherit policy uses the parent scope's decision.",
"i18n:govoplan-mail.smtp_credential_selection": "SMTP credential selection",
"i18n:govoplan-mail.imap_credential_selection": "IMAP credential selection",
"i18n:govoplan-mail.credential_policy_parent": "Inherit policy from parent",
"i18n:govoplan-mail.credential_policy_profile": "Allow profile default credential",
"i18n:govoplan-mail.credential_policy_explicit": "Require explicit Mail credential",
"i18n:govoplan-mail.credential_policy_parent_locked": "An ancestor has locked credential selection. Change the controlling policy shown in the policy path; lower scopes cannot override or unlock it.",
"i18n:govoplan-mail.credential_policy_path": "{value0}: {value1}",
"i18n:govoplan-mail.credential_policy_path_locked": "{value0}: {value1}; lower-level override locked",
"i18n:govoplan-mail.policy_saved_refresh_failed": "Mail policy was saved, but refreshing dependent data failed: {value0}. Reload to refresh the display; the saved policy does not need to be submitted again.",
"i18n:govoplan-mail.active.a733b809": "Active",
"i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override",
"i18n:govoplan-mail.allow.3ad0e369": "Allow",
@@ -40,7 +65,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Controls credential inheritance for compatible consumers. Campaign delivery requires credentials to remain on and inherit from the selected Mail profile.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Controls credential inheritance for compatible consumers. Campaign delivery requires credentials to remain on and inherit from the selected Mail profile.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Controls whether consumers may use a selected Mail server's default credentials or must explicitly select authorized Mail-owned credentials. Explicit selection remains allowed when defaults are permitted; Campaign never stores local passwords.",
"i18n:govoplan-mail.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Description",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Development mock mailbox",
@@ -217,6 +242,31 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.working.049ac820": "Working..."
},
"de": {
"i18n:govoplan-mail.mailbox_workspace": "Postfach-Arbeitsbereich",
"i18n:govoplan-mail.mailbox_refresh_in_progress": "Warten Sie, bis die aktuelle Postfachaktualisierung abgeschlossen ist.",
"i18n:govoplan-mail.mailbox_profile": "Postfachprofil",
"i18n:govoplan-mail.no_mailbox_profiles": "Keine Postfachprofile verfügbar",
"i18n:govoplan-mail.mailbox_tools": "Postfachwerkzeuge",
"i18n:govoplan-mail.mailbox_refresh_tools": "Einen bestimmten Bereich aktualisieren",
"i18n:govoplan-mail.mailbox_refresh_tools_help": "Neuladen aktualisiert den aktuellen Postfachkontext. Diese gezielten Leseaktionen aktualisieren bei Bedarf nur die verfügbaren Profile, den Ordnerkatalog oder die aktuelle Nachrichtenseite. Gelesen-/Ungelesen-Kennzeichen und andere Anbieterzustände bleiben unverändert.",
"i18n:govoplan-mail.refresh_mailbox_profiles": "Verfügbare Profile aktualisieren",
"i18n:govoplan-mail.refresh_mailbox_folder_catalogue": "Nur Ordner aktualisieren",
"i18n:govoplan-mail.refresh_mailbox_message_index": "Nur Nachrichten aktualisieren",
"i18n:govoplan-mail.mailbox_related_tools": "Weitere Mail-Werkzeuge",
"i18n:govoplan-mail.mailbox_bounce_status": "Zustellrückläufer",
"i18n:govoplan-mail.mailbox_bounce_permission": "Eine Berechtigung zum Lesen oder Verwalten der Rückläuferverarbeitung in Mail ist erforderlich.",
"i18n:govoplan-mail.folder_group_selection": "Diese Gruppe enthält Postfachordner. Klappen Sie sie über das Ordnersymbol auf und wählen Sie einen Postfachordner aus, um Nachrichten anzuzeigen.",
"i18n:govoplan-mail.credential_selection_policy": "Auswahl der Zugangsdaten",
"i18n:govoplan-mail.credential_selection_policy_help": "Die Standard-Zugangsdaten des ausgewählten Mail-Servers zulassen oder eine ausdrückliche Auswahl berechtigter Mail-Zugangsdaten in der Kampagne verlangen. Beide Optionen belassen Geheimnisse in Mail; kampagnenlokale Passwörter sind nicht erlaubt. Richtlinie erben übernimmt die Entscheidung des übergeordneten Bereichs.",
"i18n:govoplan-mail.smtp_credential_selection": "SMTP-Zugangsdaten auswählen",
"i18n:govoplan-mail.imap_credential_selection": "IMAP-Zugangsdaten auswählen",
"i18n:govoplan-mail.credential_policy_parent": "Richtlinie vom übergeordneten Bereich erben",
"i18n:govoplan-mail.credential_policy_profile": "Standard-Zugangsdaten des Profils zulassen",
"i18n:govoplan-mail.credential_policy_explicit": "Ausdrückliche Mail-Zugangsdaten verlangen",
"i18n:govoplan-mail.credential_policy_parent_locked": "Ein übergeordneter Bereich hat die Auswahl der Zugangsdaten gesperrt. Die maßgebliche Richtlinie im Richtlinienpfad ändern; untergeordnete Bereiche können diese Vorgabe weder überschreiben noch entsperren.",
"i18n:govoplan-mail.credential_policy_path": "{value0}: {value1}",
"i18n:govoplan-mail.credential_policy_path_locked": "{value0}: {value1}; Überschreiben in unteren Bereichen gesperrt",
"i18n:govoplan-mail.policy_saved_refresh_failed": "Die Mail-Richtlinie wurde gespeichert, aber abhängige Daten konnten nicht aktualisiert werden: {value0}. Zum Aktualisieren der Anzeige neu laden; die gespeicherte Richtlinie muss nicht erneut gesendet werden.",
"i18n:govoplan-mail.active.a733b809": "Aktiv",
"i18n:govoplan-mail.allow_override.ffa6e9a0": "Allow override",
"i18n:govoplan-mail.allow.3ad0e369": "Allow",
@@ -255,7 +305,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Steuert die Vererbung fuer kompatible Verbraucher. Kampagnen muessen die Zugangsdaten aus dem ausgewaehlten Mailprofil erben.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Steuert die Vererbung fuer kompatible Verbraucher. Kampagnen muessen die Zugangsdaten aus dem ausgewaehlten Mailprofil erben.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Legt fest, ob Verbraucher die Standard-Zugangsdaten eines ausgewählten Mailservers verwenden dürfen oder ausdrücklich berechtigte, von Mail verwaltete Zugangsdaten auswählen müssen. Eine ausdrückliche Auswahl bleibt auch bei erlaubten Standard-Zugangsdaten möglich; Campaign speichert niemals lokale Passwörter.",
"i18n:govoplan-mail.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Entwicklungs-Mailbox",
+23 -68
View File
@@ -181,18 +181,6 @@
flex-direction: column;
}
}
.mailbox-page {
display: grid;
gap: 18px;
}
.mailbox-toolbar label {
display: grid;
gap: 5px;
min-width: min(340px, 100%);
}
.mailbox-toolbar label span,
.mailbox-preview-header span {
color: var(--muted);
font-size: 12px;
@@ -200,15 +188,6 @@
text-transform: uppercase;
}
.mailbox-toolbar-meta {
flex: 1;
min-width: 0;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-layout {
display: grid;
grid-template-columns: minmax(190px, 240px) minmax(0, 1.25fr) minmax(320px, .75fr);
@@ -388,11 +367,6 @@
}
@media (max-width: 760px) {
.mailbox-toolbar {
align-items: stretch;
flex-direction: column;
}
.mailbox-layout {
grid-template-columns: 1fr;
}
@@ -402,7 +376,10 @@
/* Mailbox work surface: shared explorer/list pattern with mail-specific columns. */
.mailbox-shell.file-manager-shell {
position: relative;
flex: 1 1 auto;
min-height: 0;
grid-template-columns: minmax(230px, 290px) minmax(390px, 1fr) minmax(340px, .82fr);
grid-template-rows: minmax(0, 1fr);
}
.mailbox-shell.is-loading .mailbox-preview-panel {
@@ -451,45 +428,11 @@
flex: 0 0 auto;
}
.mailbox-toolbar.file-manager-toolbar {
display: grid;
grid-template-columns: minmax(180px, 280px) minmax(0, 1fr) auto;
gap: 12px;
}
.mailbox-profile-field {
display: grid;
gap: 4px;
flex: 0 1 20rem;
min-width: 0;
}
.mailbox-profile-field > span {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.mailbox-toolbar-meta {
min-width: 0;
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.mailbox-toolbar-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.mailbox-toolbar-actions .btn {
white-space: nowrap;
}
.mailbox-profile-blocker {
margin: 12px;
}
@@ -680,25 +623,37 @@
@media (max-width: 1280px) {
.mailbox-shell.file-manager-shell {
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
grid-template-rows: minmax(0, 1.4fr) minmax(0, 1fr);
}
.mailbox-preview-panel {
grid-column: 1 / -1;
min-height: 320px;
min-height: 0;
border-top: var(--border-line);
}
}
@media (max-width: 760px) {
.mailbox-toolbar.file-manager-toolbar {
grid-template-columns: 1fr;
.mailbox-shell.file-manager-shell {
grid-template-columns: minmax(0, 1fr);
/* The three domain panes no longer compete for a fraction of one short
mobile viewport. Scroll this bounded workspace, not the action header;
the index and preview retain their own usable, bounded scroll regions. */
grid-template-rows: 8rem max-content minmax(14rem, 35dvh);
align-content: start;
overflow-y: auto;
}
.mailbox-toolbar-actions {
justify-content: flex-start;
.mailbox-page .mailbox-message-list-panel {
display: grid;
/* Header and pagination take their intrinsic space. The message viewport
has its own bound, so translated wrapping cannot squeeze it to zero. */
grid-template-rows: auto clamp(8rem, 24dvh, 14rem) auto;
border-right: 0;
border-top: var(--border-line);
}
.mailbox-message-head {
.mailbox-page .mailbox-message-head {
display: none;
}
@@ -706,7 +661,7 @@
min-width: 0;
}
.mailbox-message-row {
.mailbox-page .mailbox-message-row {
grid-template-columns: 1fr;
gap: 8px;
}