1296 lines
59 KiB
TypeScript
1296 lines
59 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import { FieldLabel, MailServerSettingsPanel, PolicyLockedHint, PolicySourcePath, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, normalizeMailServerSecurity, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings, type PolicySourcePathItem } from "@govoplan/core-webui";
|
|
import { Pencil, Plus, Trash2 } from "lucide-react";
|
|
import type { ApiSettings } from "../../types";
|
|
import {
|
|
createMailServerProfile,
|
|
deactivateMailServerProfile,
|
|
getMailProfilePolicy,
|
|
listImapFolders,
|
|
listMailProfileImapFolders,
|
|
listMailServerProfiles,
|
|
mailProfilePatternKeys,
|
|
mailProfilePolicyLimitKeys,
|
|
updateMailProfilePolicy,
|
|
testImapSettings,
|
|
testMailProfileImap,
|
|
testMailProfileSmtp,
|
|
testSmtpSettings,
|
|
updateMailServerProfile,
|
|
type MailCredentialPolicy,
|
|
type MailImapTestPayload,
|
|
type MailProfilePatternKey,
|
|
type MailProfilePatternRules,
|
|
type MailProfilePolicy,
|
|
type MailProfilePolicyLimitKey,
|
|
type MailProfileScope,
|
|
type MailSecurity,
|
|
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";
|
|
import { ConfirmDialog } from "@govoplan/core-webui";
|
|
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
|
import { Dialog } from "@govoplan/core-webui";
|
|
import { DismissibleAlert } from "@govoplan/core-webui";
|
|
import { FormField } from "@govoplan/core-webui";
|
|
export type MailProfileTargetOption = {
|
|
id: string;
|
|
label: string;
|
|
secondary?: string | null;
|
|
};
|
|
|
|
type ProfileDraft = {
|
|
name: string;
|
|
slug: string;
|
|
description: string;
|
|
isActive: boolean;
|
|
smtpHost: string;
|
|
smtpPort: string;
|
|
smtpSecurity: MailSecurity;
|
|
smtpUsername: string;
|
|
smtpPassword: string;
|
|
smtpTimeout: string;
|
|
imapHost: string;
|
|
imapPort: string;
|
|
imapSecurity: MailSecurity;
|
|
imapUsername: string;
|
|
imapPassword: string;
|
|
imapSentFolder: string;
|
|
imapTimeout: string;
|
|
};
|
|
|
|
type MailProfileScopeManagerProps = {
|
|
settings: ApiSettings;
|
|
scopeType: MailProfileScope;
|
|
scopeId?: string | null;
|
|
targetOptions?: MailProfileTargetOption[];
|
|
targetLabel?: string;
|
|
profileTitle?: string;
|
|
policyTitle?: string;
|
|
canWriteProfiles: boolean;
|
|
canManageCredentials: boolean;
|
|
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 CredentialInheritanceValue = "inherit" | "profile" | "local";
|
|
|
|
const securityOptions = mailServerSecurityOptions as readonly MailSecurity[];
|
|
|
|
const patternLabels: Record<MailProfilePatternKey, string> = {
|
|
smtp_hosts: "SMTP hostnames",
|
|
imap_hosts: "IMAP hostnames",
|
|
envelope_senders: "Envelope senders",
|
|
from_headers: "From headers",
|
|
recipient_domains: "Recipient domains"
|
|
};
|
|
|
|
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,
|
|
scopeType,
|
|
scopeId = null,
|
|
targetOptions = [],
|
|
targetLabel = "Target",
|
|
profileTitle = "Mail server profiles",
|
|
policyTitle = "Mail profile policy",
|
|
canWriteProfiles,
|
|
canManageCredentials,
|
|
canWritePolicy
|
|
}: MailProfileScopeManagerProps) {
|
|
const [profiles, setProfiles] = useState<MailServerProfile[]>([]);
|
|
const [selectedTargetId, setSelectedTargetId] = useState(scopeId || targetOptions[0]?.id || "");
|
|
const [editing, setEditing] = useState<EditingProfile>(null);
|
|
const [deactivating, setDeactivating] = useState<MailServerProfile | null>(null);
|
|
const [draft, setDraft] = useState<ProfileDraft>(emptyProfileDraft());
|
|
const [loading, setLoading] = useState(true);
|
|
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 targetSelectionRequired = requiresTarget && !scopeId;
|
|
const hasSelectableTarget = targetOptions.length > 0;
|
|
const activeScopeId = scopeId || selectedTargetId || null;
|
|
const scopeReady = !requiresTarget || Boolean(activeScopeId);
|
|
const targetEmptyText = `No ${pluralizeLabel(targetLabel.toLowerCase())} available`;
|
|
|
|
useEffect(() => {
|
|
if (scopeId) {
|
|
setSelectedTargetId(scopeId);
|
|
return;
|
|
}
|
|
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
|
|
setSelectedTargetId(targetOptions[0].id);
|
|
return;
|
|
}
|
|
if (targetOptions.length === 0 && selectedTargetId) setSelectedTargetId("");
|
|
}, [scopeId, selectedTargetId, targetOptions]);
|
|
|
|
useEffect(() => { void loadProfiles(); }, [activeScopeId, scopeReady, scopeType, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
|
|
|
async function loadProfiles() {
|
|
setLoading(true);
|
|
setError("");
|
|
if (!scopeReady) {
|
|
setProfiles([]);
|
|
setProfileEffectivePolicy(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
try {
|
|
const [nextProfiles, policyResponse] = await Promise.all([
|
|
listMailServerProfiles(settings, true),
|
|
getMailProfilePolicy(settings, scopeType, activeScopeId)
|
|
]);
|
|
setProfiles(nextProfiles);
|
|
setProfileEffectivePolicy(policyResponse?.effective_policy ?? null);
|
|
} catch (err) {
|
|
setProfiles([]);
|
|
setProfileEffectivePolicy(null);
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
const scopedProfiles = useMemo(
|
|
() => profiles.filter((profile) => profileBelongsToEditableScope(profile, scopeType, activeScopeId)),
|
|
[activeScopeId, profiles, scopeType]
|
|
);
|
|
|
|
function openCreate() {
|
|
setDraft(emptyProfileDraft());
|
|
setEditing("new");
|
|
setError("");
|
|
setSuccess("");
|
|
}
|
|
|
|
function openEdit(profile: MailServerProfile) {
|
|
setDraft(profileToDraft(profile));
|
|
setEditing(profile);
|
|
setError("");
|
|
setSuccess("");
|
|
}
|
|
|
|
async function saveProfile() {
|
|
if (!editing || !scopeReady) return;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
if (editing === "new") {
|
|
const payload = createProfilePayload(draft, scopeType, activeScopeId);
|
|
const created = await createMailServerProfile(settings, payload);
|
|
setSuccess(`Profile ${created.name} created.`);
|
|
} else {
|
|
const payload = updateProfilePayload(draft, editing);
|
|
const updated = await updateMailServerProfile(settings, editing.id, payload);
|
|
setSuccess(`Profile ${updated.name} updated.`);
|
|
}
|
|
setEditing(null);
|
|
await loadProfiles();
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function deactivateProfile() {
|
|
if (!deactivating) return;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
await deactivateMailServerProfile(settings, deactivating.id);
|
|
setSuccess(`Profile ${deactivating.name} deactivated.`);
|
|
setDeactivating(null);
|
|
await loadProfiles();
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const columns = useMemo<DataGridColumn<MailServerProfile>[]>(() => [
|
|
{ id: "name", header: "Profile", width: "minmax(220px, 1fr)", sticky: "start", resizable: true, sortable: true, filterable: true, value: (profile) => `${profile.name} ${profile.slug}`, render: (profile) => <div><strong>{profile.name}</strong><div className="muted small-note">{profile.slug}</div></div> },
|
|
{ id: "scope", header: "Scope", width: 120, resizable: true, sortable: true, filterable: true, value: (profile) => scopeLabel(profile) },
|
|
{ id: "smtp", header: "SMTP", width: 220, sortable: true, filterable: true, value: (profile) => transportLabel(profile.smtp), render: (profile) => <TransportCell profile={profile} protocol="smtp" /> },
|
|
{ id: "imap", header: "IMAP", width: 220, sortable: true, filterable: true, value: (profile) => profile.imap ? transportLabel(profile.imap) : "Not configured", render: (profile) => <TransportCell profile={profile} protocol="imap" /> },
|
|
{ id: "status", header: "Status", width: 110, sortable: true, filterable: true, value: (profile) => profile.is_active ? "Active" : "Inactive", render: (profile) => <span className={`status-badge ${profile.is_active ? "success" : "neutral"}`}>{profile.is_active ? "Active" : "Inactive"}</span> },
|
|
{ id: "actions", header: "Actions", width: 145, sticky: "end", align: "right", render: (profile) => <div className="admin-icon-actions">
|
|
<Button type="button" className="admin-icon-button" title={`Edit ${profile.name}`} aria-label={`Edit ${profile.name}`} onClick={() => openEdit(profile)} disabled={!canWriteProfiles || busy}><Pencil size={16} /></Button>
|
|
<Button type="button" variant="danger" className="admin-icon-button" title={`Deactivate ${profile.name}`} aria-label={`Deactivate ${profile.name}`} onClick={() => setDeactivating(profile)} disabled={!canWriteProfiles || busy || !profile.is_active}><Trash2 size={16} /></Button>
|
|
</div> }
|
|
], [busy, canWriteProfiles]);
|
|
|
|
return (
|
|
<div className="mail-profile-manager">
|
|
{targetSelectionRequired && (
|
|
<Card title={`${targetLabel} scope`}>
|
|
<div className="mail-profile-target-row">
|
|
<FormField label={targetLabel}>
|
|
<select value={selectedTargetId} onChange={(event) => setSelectedTargetId(event.target.value)} disabled={loading || busy || !hasSelectableTarget}>
|
|
{!hasSelectableTarget && <option value="">{targetEmptyText}</option>}
|
|
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
|
|
</select>
|
|
</FormField>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{scopeReady && (
|
|
<>
|
|
<MailProfilePolicyEditor
|
|
settings={settings}
|
|
scopeType={scopeType}
|
|
scopeId={activeScopeId}
|
|
profiles={profiles}
|
|
canWrite={canWritePolicy}
|
|
title={policyTitle}
|
|
onSaved={loadProfiles}
|
|
/>
|
|
|
|
<Card
|
|
title={profileTitle}
|
|
actions={
|
|
<div className="button-row compact-actions">
|
|
<Button onClick={() => void loadProfiles()} disabled={loading}>{loading ? "Loading…" : "Reload"}</Button>
|
|
<Button variant="primary" onClick={openCreate} disabled={!canWriteProfiles || busy}><span className="button-icon-label"><Plus size={16} />New profile</span></Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="admin-table-surface mail-profile-table-surface">
|
|
<DataGrid
|
|
id={`mail-profiles-${scopeType}-${activeScopeId || "root"}`}
|
|
rows={scopedProfiles}
|
|
columns={columns}
|
|
initialFit="container"
|
|
getRowKey={(profile) => profile.id}
|
|
emptyText="No profiles in this scope."
|
|
/>
|
|
</div>
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{error && <DismissibleAlert tone="danger" resetKey={error} floating>{error}</DismissibleAlert>}
|
|
{success && <DismissibleAlert tone="success" resetKey={success} floating>{success}</DismissibleAlert>}
|
|
|
|
<Dialog
|
|
open={editing !== null}
|
|
title={editing === "new" ? "Create mail profile" : "Edit mail profile"}
|
|
onClose={() => !busy && setEditing(null)}
|
|
className="admin-dialog admin-dialog-wide mail-profile-dialog"
|
|
closeDisabled={busy}
|
|
footer={<><Button onClick={() => setEditing(null)} disabled={busy}>Cancel</Button><Button variant="primary" onClick={() => void saveProfile()} disabled={!canWriteProfiles || busy || !draft.name.trim() || !scopeReady}>{busy ? "Saving…" : "Save profile"}</Button></>}
|
|
>
|
|
<ProfileForm settings={settings} draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} effectivePolicy={profileEffectivePolicy} />
|
|
</Dialog>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(deactivating)}
|
|
title="Deactivate mail profile"
|
|
message={`Deactivate ${deactivating?.name || "this profile"}? Campaign drafts using it will need another allowed profile before sending.`}
|
|
confirmLabel="Deactivate"
|
|
tone="danger"
|
|
busy={busy}
|
|
onCancel={() => setDeactivating(null)}
|
|
onConfirm={() => void deactivateProfile()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function MailProfilePolicyEditor({
|
|
settings,
|
|
scopeType,
|
|
scopeId = null,
|
|
campaignId = null,
|
|
profiles,
|
|
ownerUserId = null,
|
|
ownerGroupId = null,
|
|
canWrite,
|
|
locked = false,
|
|
title = "Mail profile policy",
|
|
description = "Allowed profiles and wildcard rules for this scope.",
|
|
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 [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);
|
|
|
|
useEffect(() => { void loadPolicy(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey, scopeType, scopeId, campaignId]);
|
|
|
|
async function loadPolicy() {
|
|
setError("");
|
|
setSuccess("");
|
|
if (!scopeReady) {
|
|
setPolicy(blankPolicy);
|
|
setEffectivePolicy(null);
|
|
setParentPolicy(null);
|
|
setEffectivePolicySources([]);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const response = await getMailProfilePolicy(settings, scopeType, scopeId, campaignId);
|
|
setPolicy(normalizePolicy(response.policy));
|
|
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);
|
|
setEffectivePolicy(null);
|
|
setParentPolicy(null);
|
|
setEffectivePolicySources([]);
|
|
setError(errorMessage(err));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function savePolicy() {
|
|
if (!scopeReady) return;
|
|
setBusy(true);
|
|
setError("");
|
|
setSuccess("");
|
|
try {
|
|
const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicyForSave(policy, parentPolicy, scopeType), scopeId);
|
|
setPolicy(normalizePolicy(response.policy));
|
|
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
|
|
setParentPolicy(response.parent_policy ? normalizePolicy(response.parent_policy) : null);
|
|
setEffectivePolicySources(response.effective_policy_sources ?? []);
|
|
setSuccess("Mail profile policy saved.");
|
|
await onSaved?.();
|
|
} catch (err) {
|
|
setError(errorMessage(err));
|
|
} 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 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 smtpCredentialLocked = !parentAllowsMailLimit("smtp_credentials.inherit");
|
|
const imapCredentialLocked = !parentAllowsMailLimit("imap_credentials.inherit");
|
|
const showAllowColumn = scopeType !== "campaign";
|
|
const showEffectiveColumn = !isSystem;
|
|
const profileAllowListLocked = !parentAllowsMailLimit("allowed_profile_ids");
|
|
const blockedProfileDefinitions = [
|
|
parentBlocksUserProfiles ? "user" : "",
|
|
parentBlocksGroupProfiles ? "group" : "",
|
|
parentBlocksCampaignProfiles ? "campaign-local settings" : ""
|
|
].filter(Boolean).join(", ");
|
|
const lockedCredentialKinds = [smtpCredentialLocked ? "SMTP" : "", imapCredentialLocked ? "IMAP" : ""].filter(Boolean).join(" and ");
|
|
const effectivePolicyPath = effectivePolicySources.length > 0 ? effectivePolicySources : mailPolicySourcePath(scopeType);
|
|
|
|
function patchPolicy(patch: Partial<MailProfilePolicy>) {
|
|
setPolicy((current) => normalizePolicy({ ...current, ...patch }));
|
|
}
|
|
|
|
function toggleAllowedProfile(profileId: string, checked: boolean) {
|
|
const next = new Set(policy.allowed_profile_ids ?? []);
|
|
if (checked) next.add(profileId);
|
|
else next.delete(profileId);
|
|
patchPolicy({ allowed_profile_ids: [...next].sort() });
|
|
}
|
|
|
|
function setFlag(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", value: PolicyFlagValue) {
|
|
patchPolicy({ [key]: flagToBoolean(value) });
|
|
}
|
|
|
|
function setCredentialPolicy(protocol: "smtp" | "imap", patch: Partial<MailCredentialPolicy>) {
|
|
const key = protocol === "smtp" ? "smtp_credentials" : "imap_credentials";
|
|
patchPolicy({ [key]: normalizeCredentialPolicy({ ...(policy[key] ?? {}), ...patch }) });
|
|
}
|
|
|
|
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 = "Allow override"): 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">
|
|
<Button onClick={() => void loadPolicy()} disabled={loading || !scopeReady}>{loading ? "Loading…" : "Reload"}</Button>
|
|
<Button variant="primary" onClick={() => void savePolicy()} disabled={disabled}>{busy ? "Saving…" : "Save policy"}</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="mail-policy-editor">
|
|
{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">
|
|
<div className="subsection-heading split">
|
|
<h3>Profile allow-list</h3>
|
|
<div className="button-row compact-actions">
|
|
{lowerLevelLimitToggle("allowed_profile_ids")}
|
|
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || profileAllowListLocked || selectedProfileIds.size === 0}>Clear allow-list</Button>
|
|
</div>
|
|
</div>
|
|
<p className="muted small-note">{selectedProfileIds.size === 0 ? "No local profile allow-list is set." : `${selectedProfileIds.size} profile(s) allowed by this scope.`}</p>
|
|
<div className="mail-profile-checkbox-grid">
|
|
{candidateProfiles.map((profile) => (
|
|
<label className="mail-profile-checkbox" key={profile.id}>
|
|
<input type="checkbox" checked={selectedProfileIds.has(profile.id)} disabled={disabled || profileAllowListLocked || Boolean(parentAllowedProfileIds && !parentAllowedProfileIds.has(profile.id) && !selectedProfileIds.has(profile.id))} onChange={(event) => toggleAllowedProfile(profile.id, event.target.checked)} />
|
|
<span><strong>{profile.name}</strong><small>{scopeLabel(profile)} · {transportLabel(profile.smtp)}</small></span>
|
|
</label>
|
|
))}
|
|
{candidateProfiles.length === 0 && <p className="muted small-note">No profiles are visible for this policy scope.</p>}
|
|
</div>
|
|
{parentAllowedProfileIds && <p className="muted small-note">An ancestor allow-list limits selectable profiles to {parentAllowedProfileIds.size} profile(s).</p>}
|
|
</section>
|
|
|
|
<section className="mail-policy-section">
|
|
<h3>Lower-level mail definitions</h3>
|
|
<MailPolicyTable showAllowColumn={showAllowColumn} showEffectiveColumn={showEffectiveColumn} settingLabel={isSystem ? "Value" : "Local setting"}>
|
|
<MailPolicyRow
|
|
label="User profiles"
|
|
help={policyHelp("Controls whether user-scoped mail profiles may be defined below this scope.")}
|
|
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={lowerLevelLimitToggle("allow_user_profiles")}
|
|
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_user_profiles", effectivePolicyPath) : undefined}
|
|
/>
|
|
<MailPolicyRow
|
|
label="Group profiles"
|
|
help={policyHelp("Controls whether group-scoped mail profiles may be defined below this scope.")}
|
|
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={lowerLevelLimitToggle("allow_group_profiles")}
|
|
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_group_profiles", effectivePolicyPath) : undefined}
|
|
/>
|
|
<MailPolicyRow
|
|
label="Campaign-local settings"
|
|
help={policyHelp("Controls whether campaigns may use inline SMTP/IMAP settings instead of reusable profiles.")}
|
|
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={lowerLevelLimitToggle("allow_campaign_profiles")}
|
|
effectiveHelp={showEffectiveColumn ? mailBooleanPolicyPathHelp("allow_campaign_profiles", effectivePolicyPath) : undefined}
|
|
/>
|
|
</MailPolicyTable>
|
|
{blockedProfileDefinitions && <PolicyLockedHint>Explicit allow is unavailable for {blockedProfileDefinitions} because an ancestor policy blocks those definitions.</PolicyLockedHint>}
|
|
</section>
|
|
|
|
<section className="mail-policy-section">
|
|
<h3>Credential inheritance</h3>
|
|
<MailPolicyTable showAllowColumn={showAllowColumn} showEffectiveColumn={showEffectiveColumn} settingLabel={isSystem ? "Value" : "Local setting"}>
|
|
<MailPolicyRow
|
|
label="SMTP credentials"
|
|
help={policyHelp("Decides whether lower scopes inherit saved SMTP credentials from a selected profile or must provide local SMTP credentials.")}
|
|
control={<CredentialInheritanceControl value={credentialInheritanceToValue(displayPolicy.smtp_credentials?.inherit)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && smtpCredentialLocked} onChange={(value) => setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} />}
|
|
effective={showEffectiveColumn ? (effectivePolicy ? credentialInheritanceLabel(effectivePolicy.smtp_credentials) : "Loading...") : undefined}
|
|
allowControl={lowerLevelLimitToggle("smtp_credentials.inherit")}
|
|
effectiveHelp={showEffectiveColumn ? mailCredentialPolicyPathHelp("smtp_credentials", effectivePolicyPath) : undefined}
|
|
/>
|
|
<MailPolicyRow
|
|
label="IMAP credentials"
|
|
help={policyHelp("Decides whether lower scopes inherit saved IMAP credentials from a selected profile or must provide local IMAP credentials.")}
|
|
control={<CredentialInheritanceControl value={credentialInheritanceToValue(displayPolicy.imap_credentials?.inherit)} disabled={disabled} includeInherit={!isSystem} inheritOnly={!isSystem && imapCredentialLocked} onChange={(value) => setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} />}
|
|
effective={showEffectiveColumn ? (effectivePolicy ? credentialInheritanceLabel(effectivePolicy.imap_credentials) : "Loading...") : undefined}
|
|
allowControl={lowerLevelLimitToggle("imap_credentials.inherit")}
|
|
effectiveHelp={showEffectiveColumn ? mailCredentialPolicyPathHelp("imap_credentials", effectivePolicyPath) : undefined}
|
|
/>
|
|
</MailPolicyTable>
|
|
{lockedCredentialKinds && <PolicyLockedHint>{lockedCredentialKinds} credential inheritance is locked by an ancestor policy.</PolicyLockedHint>}
|
|
</section>
|
|
|
|
<section className="mail-policy-section">
|
|
<h3>Wildcard rules</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>Policy target</span>
|
|
<span>Whitelist</span>
|
|
<span>Blacklist</span>
|
|
{showAllowColumn && <span>Lower levels</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, "Whitelist")}
|
|
{lowerLevelLimitToggle(`blacklist.${key}` as MailProfilePolicyLimitKey, "Blacklist")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{showEffectiveColumn && effectivePolicy && (
|
|
<section className="mail-policy-section mail-policy-effective">
|
|
<h3>Policy path</h3>
|
|
<PolicySourcePath items={effectivePolicyPath} />
|
|
<p className="muted small-note">Effective values are shown in the table rows above.</p>
|
|
</section>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function ProfileForm({
|
|
settings,
|
|
draft,
|
|
setDraft,
|
|
editing,
|
|
busy,
|
|
canWrite,
|
|
canManageCredentials,
|
|
effectivePolicy
|
|
}: {
|
|
settings: ApiSettings;
|
|
draft: ProfileDraft;
|
|
setDraft: (draft: ProfileDraft) => void;
|
|
editing: EditingProfile;
|
|
busy: boolean;
|
|
canWrite: boolean;
|
|
canManageCredentials: boolean;
|
|
effectivePolicy?: MailProfilePolicy | null;
|
|
}) {
|
|
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
|
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
|
|
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
|
|
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
|
|
|
|
const disabled = busy || !canWrite;
|
|
const credentialDisabled = disabled || !canManageCredentials;
|
|
const existingProfile = editing !== "new" ? editing : null;
|
|
const draftHasImap = hasDraftImapSettings(draft);
|
|
const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured);
|
|
const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured);
|
|
const policyMessages = useMemo(() => validateMailPolicy(effectivePolicy, {
|
|
smtpHost: draft.smtpHost,
|
|
imapHost: draft.imapHost
|
|
}), [draft.imapHost, draft.smtpHost, effectivePolicy]);
|
|
|
|
useEffect(() => {
|
|
setSmtpTestResult(null);
|
|
setImapTestResult(null);
|
|
setFolderResult(null);
|
|
setMailActionState(null);
|
|
}, [editing]);
|
|
|
|
function patchSmtpSettings(patch: Partial<MailServerSmtpSettings>) {
|
|
setDraft({
|
|
...draft,
|
|
smtpHost: patch.host !== undefined ? String(patch.host ?? "") : draft.smtpHost,
|
|
smtpPort: patch.port !== undefined ? String(patch.port ?? "") : draft.smtpPort,
|
|
smtpSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "starttls"), "starttls") : draft.smtpSecurity,
|
|
smtpTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.smtpTimeout
|
|
});
|
|
}
|
|
|
|
function patchImapSettings(patch: Partial<MailServerImapSettings>) {
|
|
setDraft({
|
|
...draft,
|
|
imapHost: patch.host !== undefined ? String(patch.host ?? "") : draft.imapHost,
|
|
imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort,
|
|
imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity,
|
|
imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder,
|
|
imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout
|
|
});
|
|
}
|
|
|
|
|
|
|
|
function patchSmtpCredentials(patch: Partial<MailServerCredentialSettings>) {
|
|
setDraft({
|
|
...draft,
|
|
smtpUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.smtpUsername,
|
|
smtpPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.smtpPassword
|
|
});
|
|
}
|
|
|
|
function patchImapCredentials(patch: Partial<MailServerCredentialSettings>) {
|
|
setDraft({
|
|
...draft,
|
|
imapUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.imapUsername,
|
|
imapPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.imapPassword
|
|
});
|
|
}
|
|
|
|
async function runSmtpTest() {
|
|
setMailActionState("smtp");
|
|
setSmtpTestResult(null);
|
|
try {
|
|
setSmtpTestResult(useSavedSmtpTest && existingProfile
|
|
? await testMailProfileSmtp(settings, existingProfile.id)
|
|
: await testSmtpSettings(settings, rawSmtpPayload(draft, false)));
|
|
} catch (err) {
|
|
setSmtpTestResult({ ok: false, protocol: "smtp", message: errorMessage(err), details: {} });
|
|
} finally {
|
|
setMailActionState(null);
|
|
}
|
|
}
|
|
|
|
async function runImapTest() {
|
|
if (!draftHasImap) return;
|
|
setMailActionState("imap");
|
|
setImapTestResult(null);
|
|
try {
|
|
setImapTestResult(useSavedImapTest && existingProfile
|
|
? await testMailProfileImap(settings, existingProfile.id)
|
|
: await testImapSettings(settings, rawImapPayload(draft, false)));
|
|
} catch (err) {
|
|
setImapTestResult({ ok: false, protocol: "imap", message: errorMessage(err), details: {} });
|
|
} finally {
|
|
setMailActionState(null);
|
|
}
|
|
}
|
|
|
|
async function runFolderLookup() {
|
|
if (!draftHasImap) return;
|
|
setMailActionState("folders");
|
|
setFolderResult(null);
|
|
try {
|
|
setFolderResult(useSavedImapTest && existingProfile
|
|
? await listMailProfileImapFolders(settings, existingProfile.id)
|
|
: await listImapFolders(settings, rawImapPayload(draft, false)));
|
|
} catch (err) {
|
|
setFolderResult({ ok: false, message: errorMessage(err), folders: [] });
|
|
} finally {
|
|
setMailActionState(null);
|
|
}
|
|
}
|
|
|
|
function useDetectedSentFolder() {
|
|
const folder = folderResult?.detected_sent_folder;
|
|
if (!folder || disabled || !draftHasImap) return;
|
|
setDraft({ ...draft, imapSentFolder: folder });
|
|
}
|
|
|
|
return (
|
|
<div className="mail-profile-form">
|
|
<div className="admin-form-grid two-columns">
|
|
<FormField label="Name"><input value={draft.name} disabled={disabled} onChange={(event) => setDraft({ ...draft, name: event.target.value })} /></FormField>
|
|
<FormField label="Slug"><input value={draft.slug} disabled={disabled} onChange={(event) => setDraft({ ...draft, slug: event.target.value })} placeholder="Generated from name" /></FormField>
|
|
<FormField label="Status"><select value={draft.isActive ? "active" : "inactive"} disabled={disabled} onChange={(event) => setDraft({ ...draft, isActive: event.target.value === "active" })}><option value="active">Active</option><option value="inactive">Inactive</option></select></FormField>
|
|
<FormField label="Description"><textarea rows={3} value={draft.description} disabled={disabled} onChange={(event) => setDraft({ ...draft, description: event.target.value })} /></FormField>
|
|
</div>
|
|
|
|
{policyMessages.length > 0 && (
|
|
<DismissibleAlert tone="warning" resetKey={policyMessages.map((item) => `${item.key}:${item.value}`).join("|")} dismissible={false}>
|
|
<strong>Effective mail policy blocks the current profile values.</strong>
|
|
<ul>{policyMessages.map((item) => <li key={`${item.key}:${item.value}`}>{item.message}</li>)}</ul>
|
|
</DismissibleAlert>
|
|
)}
|
|
|
|
<MailServerSettingsPanel
|
|
smtp={{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
|
|
imap={{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
|
|
smtpCredentials={{ username: draft.smtpUsername, password: draft.smtpPassword }}
|
|
imapCredentials={{ username: draft.imapUsername, password: draft.imapPassword }}
|
|
onSmtpChange={patchSmtpSettings}
|
|
onImapChange={patchImapSettings}
|
|
onSmtpCredentialsChange={patchSmtpCredentials}
|
|
onImapCredentialsChange={patchImapCredentials}
|
|
smtpDisabled={disabled}
|
|
smtpCredentialDisabled={credentialDisabled}
|
|
smtpPasswordSaved={Boolean(existingProfile?.smtp_password_configured)}
|
|
imapServerDisabled={disabled}
|
|
imapCredentialDisabled={credentialDisabled}
|
|
imapPasswordSaved={Boolean(existingProfile?.imap_password_configured)}
|
|
imapActionDisabled={disabled || !draftHasImap}
|
|
smtpTestLabel={useSavedSmtpTest ? "Test saved SMTP" : "Test SMTP"}
|
|
imapTestLabel={useSavedImapTest ? "Test saved IMAP" : "Test IMAP"}
|
|
busyAction={mailActionState}
|
|
onTestSmtp={() => void runSmtpTest()}
|
|
onTestImap={() => void runImapTest()}
|
|
onLookupFolders={() => void runFolderLookup()}
|
|
smtpTestResult={smtpTestResult}
|
|
imapTestResult={imapTestResult}
|
|
folderLookupResult={folderResult}
|
|
onUseDetectedFolder={useDetectedSentFolder}
|
|
useDetectedFolderDisabled={disabled || !draftHasImap}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MailPolicyTable({ children, showAllowColumn, showEffectiveColumn, settingLabel }: { children: ReactNode; showAllowColumn: boolean; showEffectiveColumn: boolean; settingLabel: string }) {
|
|
return (
|
|
<div className={`mail-policy-table policy-table${showEffectiveColumn ? " with-effective-column" : ""}${showAllowColumn ? " with-allow-column" : ""}`}>
|
|
<div className="mail-policy-row policy-row mail-policy-row-header policy-row-header">
|
|
<span>Policy</span>
|
|
<span>{settingLabel}</span>
|
|
{showEffectiveColumn && <span>Effective policy</span>}
|
|
{showAllowColumn && <span>Lower levels</span>}
|
|
</div>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MailPolicyRow({ label, help, control, effective, effectiveHelp, allowControl }: { label: string; help: ReactNode; control: ReactNode; effective?: string; effectiveHelp?: ReactNode; allowControl?: ReactNode }) {
|
|
return (
|
|
<div className="mail-policy-row policy-row">
|
|
<div className="mail-policy-field-label policy-field-label">
|
|
<FieldLabel className="mail-policy-field-title policy-field-title" help={help}>{label}</FieldLabel>
|
|
</div>
|
|
<div className="mail-policy-control policy-control">{control}</div>
|
|
{effective !== undefined && (
|
|
<div className="mail-policy-effective-cell policy-effective-cell">
|
|
<FieldLabel className="mail-policy-effective-value policy-effective-value" help={effectiveHelp}>{effective}</FieldLabel>
|
|
</div>
|
|
)}
|
|
{allowControl !== undefined && <div className="mail-policy-lower-cell policy-lower-cell">{allowControl}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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">Inherit</option>}
|
|
{!inheritOnly && <option value="allow" disabled={allowDisabled}>Explicit allow</option>}
|
|
{!inheritOnly && <option value="deny">Deny</option>}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
function CredentialInheritanceControl({ value, disabled, includeInherit = true, inheritOnly = false, onChange }: { value: CredentialInheritanceValue; disabled: boolean; includeInherit?: boolean; inheritOnly?: boolean; onChange: (value: CredentialInheritanceValue) => void }) {
|
|
const selectedValue = inheritOnly ? "inherit" : value;
|
|
return (
|
|
<select value={selectedValue} disabled={disabled} onChange={(event) => onChange(event.target.value as CredentialInheritanceValue)}>
|
|
{(includeInherit || inheritOnly) && <option value="inherit">Inherit parent decision</option>}
|
|
{!inheritOnly && <option value="profile">Inherit profile credentials</option>}
|
|
{!inheritOnly && <option value="local">Require local credentials</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 pluralizeLabel(label: string): string {
|
|
if (label.endsWith("s")) return label;
|
|
if (label.endsWith("y")) return `${label.slice(0, -1)}ies`;
|
|
return `${label}s`;
|
|
}
|
|
|
|
function TransportCell({ profile, protocol }: { profile: MailServerProfile; protocol: "smtp" | "imap" }) {
|
|
const transport = protocol === "smtp" ? profile.smtp : profile.imap;
|
|
if (!transport) return <span className="muted">Not configured</span>;
|
|
const hasPassword = protocol === "smtp" ? profile.smtp_password_configured : profile.imap_password_configured;
|
|
return <div><strong>{transportLabel(transport)}</strong><div className="muted small-note">Password {hasPassword ? "configured" : "not configured"}</div></div>;
|
|
}
|
|
|
|
function emptyProfileDraft(): ProfileDraft {
|
|
return {
|
|
name: "",
|
|
slug: "",
|
|
description: "",
|
|
isActive: true,
|
|
smtpHost: "",
|
|
smtpPort: "587",
|
|
smtpSecurity: "starttls",
|
|
smtpUsername: "",
|
|
smtpPassword: "",
|
|
smtpTimeout: "30",
|
|
imapHost: "",
|
|
imapPort: "993",
|
|
imapSecurity: "tls",
|
|
imapUsername: "",
|
|
imapPassword: "",
|
|
imapSentFolder: "auto",
|
|
imapTimeout: "30"
|
|
};
|
|
}
|
|
|
|
function profileToDraft(profile: MailServerProfile): ProfileDraft {
|
|
return {
|
|
name: profile.name,
|
|
slug: profile.slug,
|
|
description: profile.description || "",
|
|
isActive: profile.is_active,
|
|
smtpHost: stringValue(profile.smtp.host),
|
|
smtpPort: stringValue(profile.smtp.port ?? 587),
|
|
smtpSecurity: readSecurity(String(profile.smtp.security || "starttls"), "starttls"),
|
|
smtpUsername: stringValue(profile.credentials?.smtp?.username ?? profile.smtp.username),
|
|
smtpPassword: "",
|
|
smtpTimeout: stringValue(profile.smtp.timeout_seconds ?? 30),
|
|
imapHost: stringValue(profile.imap?.host),
|
|
imapPort: stringValue(profile.imap?.port ?? 993),
|
|
imapSecurity: readSecurity(String(profile.imap?.security || "tls"), "tls"),
|
|
imapUsername: stringValue(profile.credentials?.imap?.username ?? profile.imap?.username),
|
|
imapPassword: "",
|
|
imapSentFolder: stringValue(profile.imap?.sent_folder || "auto"),
|
|
imapTimeout: stringValue(profile.imap?.timeout_seconds ?? 30)
|
|
};
|
|
}
|
|
|
|
function createProfilePayload(draft: ProfileDraft, scopeType: MailProfileScope, scopeId: string | null): MailServerProfilePayload {
|
|
return {
|
|
name: draft.name.trim(),
|
|
slug: mailTextOrNull(draft.slug),
|
|
description: mailTextOrNull(draft.description),
|
|
is_active: draft.isActive,
|
|
scope_type: scopeType,
|
|
scope_id: scopeId,
|
|
smtp: smtpServerPayload(draft),
|
|
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
|
|
credentials: profileCredentialsPayload(draft, false)
|
|
};
|
|
}
|
|
|
|
function updateProfilePayload(draft: ProfileDraft, profile: MailServerProfile): MailServerProfileUpdatePayload {
|
|
return {
|
|
name: draft.name.trim(),
|
|
slug: mailTextOrNull(draft.slug),
|
|
description: draft.description.trim(),
|
|
is_active: draft.isActive,
|
|
smtp: smtpServerPayload(draft),
|
|
imap: hasDraftImapSettings(draft) ? imapServerPayload(draft) : null,
|
|
credentials: profileCredentialsPayload(draft, true),
|
|
clear_imap: !hasDraftImapSettings(draft) && Boolean(profile.imap)
|
|
};
|
|
}
|
|
|
|
function smtpServerPayload(draft: ProfileDraft): MailSmtpTestPayload {
|
|
return mailSmtpSettingsPayload<MailSecurity>(
|
|
{ host: draft.smtpHost, port: draft.smtpPort, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout },
|
|
{ fallbackSecurity: "starttls", allowedSecurity: securityOptions },
|
|
);
|
|
}
|
|
|
|
function imapServerPayload(draft: ProfileDraft): MailImapTestPayload {
|
|
return mailImapSettingsPayload<MailSecurity>(
|
|
{ host: draft.imapHost, port: draft.imapPort, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout },
|
|
{ fallbackSecurity: "tls", allowedSecurity: securityOptions },
|
|
);
|
|
}
|
|
|
|
function profileCredentialsPayload(draft: ProfileDraft, preserveBlankPassword: boolean) {
|
|
return {
|
|
smtp: mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, preserveBlankPassword),
|
|
imap: mailTransportCredentialsPayload(draft.imapUsername, draft.imapPassword, preserveBlankPassword)
|
|
};
|
|
}
|
|
|
|
function rawSmtpPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailSmtpTestPayload {
|
|
return { ...smtpServerPayload(draft), ...mailTransportCredentialsPayload(draft.smtpUsername, draft.smtpPassword, preserveBlankPassword) };
|
|
}
|
|
|
|
function rawImapPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailImapTestPayload {
|
|
return { ...imapServerPayload(draft), ...mailTransportCredentialsPayload(draft.imapUsername, draft.imapPassword, preserveBlankPassword) };
|
|
}
|
|
|
|
function hasDraftImapSettings(draft: ProfileDraft): boolean {
|
|
return hasMailImapSettings([draft.imapHost, draft.imapUsername, draft.imapPassword]);
|
|
}
|
|
|
|
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 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;
|
|
if (scopeType === "system") return true;
|
|
if (scopeType === "tenant") return true;
|
|
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 credentialInheritanceToValue(value: boolean | null | undefined): CredentialInheritanceValue {
|
|
if (value === true) return "profile";
|
|
if (value === false) return "local";
|
|
return "inherit";
|
|
}
|
|
|
|
function valueToCredentialInheritance(value: CredentialInheritanceValue): boolean | null {
|
|
if (value === "profile") return true;
|
|
if (value === "local") return false;
|
|
return null;
|
|
}
|
|
|
|
function policyHelp(description: string): ReactNode {
|
|
return <span>{description}</span>;
|
|
}
|
|
|
|
type PolicySourceItem = {
|
|
label: string;
|
|
policy?: MailProfilePolicy | Record<string, unknown> | null;
|
|
};
|
|
|
|
function mailBooleanPolicyPathHelp(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", sources: PolicySourcePathItem[]): ReactNode {
|
|
return <PolicyPathHelp lines={mailBooleanPolicyPathLines(key, normalizePolicySourceItems(sources))} />;
|
|
}
|
|
|
|
function mailCredentialPolicyPathHelp(protocol: "smtp_credentials" | "imap_credentials", sources: PolicySourcePathItem[]): ReactNode {
|
|
return <PolicyPathHelp lines={mailCredentialPolicyPathLines(protocol, normalizePolicySourceItems(sources))} />;
|
|
}
|
|
|
|
function PolicyPathHelp({ lines }: { lines: string[] }) {
|
|
return (
|
|
<span className="policy-path-help">
|
|
{lines.map((line, index) => <span className="policy-path-help-line" key={`${line}-${index}`}>{line}</span>)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function mailBooleanPolicyPathLines(key: "allow_user_profiles" | "allow_group_profiles" | "allow_campaign_profiles", items: PolicySourceItem[]): string[] {
|
|
if (items.length === 0) return ["System: Allow"];
|
|
const lines: string[] = [];
|
|
for (const [index, item] of items.entries()) {
|
|
const policy = policySourceRecord(item);
|
|
const rawValue = policy[key];
|
|
const value = rawValue === true ? "Allow" : rawValue === false ? "Deny" : "Inherit";
|
|
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 mailCredentialPolicyPathLines(protocol: "smtp_credentials" | "imap_credentials", items: PolicySourceItem[]): string[] {
|
|
if (items.length === 0) return ["System: Profile inherited"];
|
|
const lines: string[] = [];
|
|
for (const [index, item] of items.entries()) {
|
|
const policy = policySourceRecord(item);
|
|
const credential = asRecord(policy[protocol]);
|
|
const rawValue = credential.inherit;
|
|
const value = rawValue === false ? "Local required" : rawValue === true ? "Profile inherited" : "Inherit";
|
|
const lowerLocked = asRecord(policy.allow_lower_level_limits)[`${protocol}.inherit`] === false;
|
|
lines.push(`${policyPathPrefix(index)}${item.label}: ${lowerLocked ? `${value} without override` : value}`);
|
|
if (lowerLocked) break;
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
function normalizePolicySourceItems(items: PolicySourcePathItem[]): PolicySourceItem[] {
|
|
return items.map((item) => {
|
|
if (typeof item === "string") return { label: item };
|
|
return { label: item.label, policy: item.policy };
|
|
}).filter((item) => item.label.trim());
|
|
}
|
|
|
|
function policySourceRecord(item: PolicySourceItem): 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 "Loading...";
|
|
return value ? "Allowed" : "Blocked";
|
|
}
|
|
|
|
function credentialInheritanceLabel(value: MailCredentialPolicy | null | undefined): string {
|
|
return value?.inherit === false ? "Local required" : "Profile inherited";
|
|
}
|
|
|
|
function patternPolicyNote(key: MailProfilePatternKey): string {
|
|
if (key === "smtp_hosts") return "SMTP server host patterns.";
|
|
if (key === "imap_hosts") return "IMAP server host patterns.";
|
|
if (key === "envelope_senders") return "SMTP envelope sender patterns.";
|
|
if (key === "from_headers") return "Visible From header patterns.";
|
|
return "Recipient domain patterns.";
|
|
}
|
|
|
|
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 "No explicit intersection";
|
|
return `${value.length} profile(s)`;
|
|
}
|
|
|
|
function mailPolicySourcePath(scopeType: MailProfileScope): string[] {
|
|
if (scopeType === "system") return ["System"];
|
|
if (scopeType === "tenant") return ["System", "Tenant"];
|
|
if (scopeType === "user") return ["System", "Tenant", "User"];
|
|
if (scopeType === "group") return ["System", "Tenant", "Group"];
|
|
return ["System", "Tenant", "Owner policy", "Campaign"];
|
|
}
|
|
|
|
function transportLabel(transport: MailSmtpTestPayload | MailImapTestPayload | null | undefined): string {
|
|
if (!transport) return "Not configured";
|
|
const host = transport.host || "No host";
|
|
const port = transport.port ? `:${transport.port}` : "";
|
|
return `${host}${port}`;
|
|
}
|
|
|
|
function scopeLabel(profile: MailServerProfile): string {
|
|
if (profile.scope_type === "system") return "System";
|
|
if (profile.scope_type === "tenant") return "Tenant";
|
|
if (profile.scope_type === "user") return "User";
|
|
if (profile.scope_type === "group") return "Group";
|
|
return "Campaign";
|
|
}
|
|
|
|
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
|
|
return normalizeMailServerSecurity<MailSecurity>(value, { fallback, allowedSecurity: securityOptions });
|
|
}
|
|
|
|
function stringValue(value: unknown): string {
|
|
if (value === null || value === undefined) return "";
|
|
return String(value);
|
|
}
|
|
|
|
function errorMessage(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|