initial commit after split

This commit is contained in:
2026-06-24 01:43:21 +02:00
parent 23213b15e2
commit d922b7701c
47 changed files with 4432 additions and 0 deletions

23
webui/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/mail-profiles.css": "./src/styles/mail-profiles.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.0",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
}
}

1
webui/src/api/client.ts Normal file
View File

@@ -0,0 +1 @@
export { apiFetch, apiUrl, authHeaders, csrfToken, apiDownload } from "@govoplan/core-webui";

265
webui/src/api/mail.ts Normal file
View File

@@ -0,0 +1,265 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type MailSecurity = "plain" | "tls" | "starttls";
export type MailProfileScope = "system" | "tenant" | "user" | "group" | "campaign";
export type MailSmtpTestPayload = {
host?: string | null;
port?: number | null;
username?: string | null;
password?: string | null;
security?: MailSecurity;
timeout_seconds?: number;
};
export type MailImapTestPayload = MailSmtpTestPayload & {
enabled?: boolean;
sent_folder?: string | null;
};
export type MailConnectionTestResponse = {
ok: boolean;
protocol: "smtp" | "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
details?: Record<string, unknown>;
};
export type MailImapFolderResponse = {
name: string;
flags?: string[];
};
export type MailImapFolderListResponse = {
ok: boolean;
protocol: "imap";
host?: string | null;
port?: number | null;
security?: MailSecurity | string | null;
message: string;
folders: MailImapFolderResponse[];
detected_sent_folder?: string | null;
details?: Record<string, unknown>;
};
export type MailServerProfile = {
id: string;
tenant_id?: string | null;
scope_type: MailProfileScope;
scope_id?: string | null;
name: string;
slug: string;
description?: string | null;
is_active: boolean;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
smtp_password_configured: boolean;
imap_password_configured: boolean;
created_at: string;
updated_at: string;
};
export type MailServerProfileListResponse = { profiles: MailServerProfile[] };
export const mailProfilePatternKeys = [
"smtp_hosts",
"imap_hosts",
"envelope_senders",
"from_headers",
"recipient_domains"
] as const;
export type MailProfilePatternKey = typeof mailProfilePatternKeys[number];
export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, string[]>>;
export type MailCredentialPolicy = {
inherit?: boolean | null;
allow_override?: boolean | null;
};
export type MailProfilePolicy = {
allowed_profile_ids?: string[] | null;
allow_user_profiles?: boolean | null;
allow_group_profiles?: boolean | null;
allow_campaign_profiles?: boolean | null;
smtp_credentials?: MailCredentialPolicy | null;
imap_credentials?: MailCredentialPolicy | null;
whitelist?: MailProfilePatternRules | null;
blacklist?: MailProfilePatternRules | null;
};
export type MailProfilePolicyResponse = {
scope_type: MailProfileScope;
scope_id?: string | null;
policy: MailProfilePolicy;
effective_policy?: MailProfilePolicy | null;
};
export type MailServerProfilePayload = {
name: string;
slug?: string | null;
description?: string | null;
is_active?: boolean;
scope_type?: MailProfileScope;
scope_id?: string | null;
smtp: MailSmtpTestPayload;
imap?: MailImapTestPayload | null;
};
export async function listMailServerProfiles(settings: ApiSettings, includeInactive = false, campaignId?: string): Promise<MailServerProfile[]> {
const params = new URLSearchParams();
if (includeInactive) params.set("include_inactive", "true");
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<MailServerProfileListResponse>(settings, `/api/v1/mail/profiles${suffix}`);
return response.profiles ?? [];
}
export async function createMailServerProfile(settings: ApiSettings, payload: MailServerProfilePayload): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(settings, "/api/v1/mail/profiles", {
method: "POST",
body: JSON.stringify(payload)
});
}
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
export async function updateMailServerProfile(settings: ApiSettings, profileId: string, payload: MailServerProfileUpdatePayload): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export async function deactivateMailServerProfile(settings: ApiSettings, profileId: string): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
}
export async function getMailProfilePolicy(
settings: ApiSettings,
scopeType: MailProfileScope,
scopeId?: string | null,
campaignId?: string | null
): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
if (campaignId) params.set("campaign_id", campaignId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`);
}
export async function updateMailProfilePolicy(
settings: ApiSettings,
scopeType: MailProfileScope,
policy: MailProfilePolicy,
scopeId?: string | null
): Promise<MailProfilePolicyResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch<MailProfilePolicyResponse>(settings, `/api/v1/mail/policies/${encodeURIComponent(scopeType)}${suffix}`, {
method: "PUT",
body: JSON.stringify({ policy })
});
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { method: "POST" });
}
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { method: "POST" });
}
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { method: "POST" });
}
export async function testSmtpSettings(
settings: ApiSettings,
payload: MailSmtpTestPayload
): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-smtp", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function testImapSettings(
settings: ApiSettings,
payload: MailImapTestPayload
): Promise<MailConnectionTestResponse> {
return apiFetch<MailConnectionTestResponse>(settings, "/api/v1/mail/test-imap", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listImapFolders(
settings: ApiSettings,
payload: MailImapTestPayload
): Promise<MailImapFolderListResponse> {
return apiFetch<MailImapFolderListResponse>(settings, "/api/v1/mail/list-imap-folders", {
method: "POST",
body: JSON.stringify(payload)
});
}
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxListResponse = {
messages: MockMailboxMessage[];
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
export type MockMailboxFailureConfig = {
fail_next_smtp?: boolean | null;
fail_next_imap?: boolean | null;
smtp_reject_recipients_containing?: string | null;
};
export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
return apiFetch<MockMailboxListResponse>(settings, `/api/v1/dev/mailbox/messages${suffix}`);
}
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
}
export async function clearMockMailboxMessages(settings: ApiSettings): Promise<{ deleted_count: number }> {
return apiFetch<{ deleted_count: number }>(settings, "/api/v1/dev/mailbox/messages", { method: "DELETE" });
}
export async function updateMockMailboxFailures(settings: ApiSettings, payload: MockMailboxFailureConfig): Promise<{ config: MockMailboxFailureConfig }> {
return apiFetch<{ config: MockMailboxFailureConfig }>(settings, "/api/v1/dev/mailbox/failures", {
method: "POST",
body: JSON.stringify(payload)
});
}

View File

@@ -0,0 +1,2 @@
import { Button } from "@govoplan/core-webui";
export default Button;

View File

@@ -0,0 +1,2 @@
import { Card } from "@govoplan/core-webui";
export default Card;

View File

@@ -0,0 +1,2 @@
import { ConfirmDialog } from "@govoplan/core-webui";
export default ConfirmDialog;

View File

@@ -0,0 +1,2 @@
import { Dialog } from "@govoplan/core-webui";
export default Dialog;

View File

@@ -0,0 +1,2 @@
import { DismissibleAlert } from "@govoplan/core-webui";
export default DismissibleAlert;

View File

@@ -0,0 +1,2 @@
import { FormField } from "@govoplan/core-webui";
export default FormField;

View File

@@ -0,0 +1,2 @@
import { LoadingFrame } from "@govoplan/core-webui";
export default LoadingFrame;

View File

@@ -0,0 +1,2 @@
import { LoadingIndicator } from "@govoplan/core-webui";
export default LoadingIndicator;

View File

@@ -0,0 +1,2 @@
import { MetricCard } from "@govoplan/core-webui";
export default MetricCard;

View File

@@ -0,0 +1,2 @@
import { PageTitle } from "@govoplan/core-webui";
export default PageTitle;

View File

@@ -0,0 +1,2 @@
import { StatusBadge } from "@govoplan/core-webui";
export default StatusBadge;

View File

@@ -0,0 +1,2 @@
import { Stepper } from "@govoplan/core-webui";
export default Stepper;

View File

@@ -0,0 +1,2 @@
import { ToggleSwitch } from "@govoplan/core-webui";
export default ToggleSwitch;

View File

@@ -0,0 +1,2 @@
import { EmailAddressInput } from "@govoplan/core-webui";
export default EmailAddressInput;

View File

@@ -0,0 +1,2 @@
import { FieldLabel } from "@govoplan/core-webui";
export default FieldLabel;

View File

@@ -0,0 +1,2 @@
import { InlineHelp } from "@govoplan/core-webui";
export default InlineHelp;

View File

@@ -0,0 +1,4 @@
import { DataGrid, DataGridEmptyAction, DataGridRowActions } from "@govoplan/core-webui";
export type { DataGridColumn, DataGridListOption, DataGridPagination, DataGridQueryState, DataGridSortDirection } from "@govoplan/core-webui";
export { DataGridEmptyAction, DataGridRowActions };
export default DataGrid;

View File

@@ -0,0 +1,864 @@
import { useEffect, useMemo, useState } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
createMailServerProfile,
deactivateMailServerProfile,
getMailProfilePolicy,
listMailServerProfiles,
mailProfilePatternKeys,
updateMailProfilePolicy,
updateMailServerProfile,
type MailCredentialPolicy,
type MailImapTestPayload,
type MailProfilePatternKey,
type MailProfilePatternRules,
type MailProfilePolicy,
type MailProfileScope,
type MailSecurity,
type MailServerProfile,
type MailServerProfilePayload,
type MailServerProfileUpdatePayload,
type MailSmtpTestPayload
} from "../../api/mail";
import Button from "../../components/Button";
import Card from "../../components/Card";
import ConfirmDialog from "../../components/ConfirmDialog";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import Dialog from "../../components/Dialog";
import DismissibleAlert from "../../components/DismissibleAlert";
import FormField from "../../components/FormField";
import ToggleSwitch from "../../components/ToggleSwitch";
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;
imapEnabled: boolean;
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";
type CredentialOverrideValue = "inherit" | "allow" | "lock";
const securityOptions: MailSecurity[] = ["plain", "tls", "starttls"];
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: {}
};
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 requiresTarget = scopeType === "user" || scopeType === "group" || scopeType === "campaign";
const activeScopeId = scopeId || selectedTargetId || null;
const scopeReady = !requiresTarget || Boolean(activeScopeId);
useEffect(() => {
if (scopeId) {
setSelectedTargetId(scopeId);
return;
}
if (targetOptions.length > 0 && !targetOptions.some((option) => option.id === selectedTargetId)) {
setSelectedTargetId(targetOptions[0].id);
}
}, [scopeId, selectedTargetId, targetOptions]);
useEffect(() => { void loadProfiles(); }, [settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
async function loadProfiles() {
setLoading(true);
setError("");
try {
setProfiles(await listMailServerProfiles(settings, true));
} catch (err) {
setProfiles([]);
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">
{targetOptions.length > 0 && !scopeId && (
<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}>
{targetOptions.map((option) => <option key={option.id} value={option.id}>{option.secondary ? `${option.label} (${option.secondary})` : option.label}</option>)}
</select>
</FormField>
</div>
</Card>
)}
<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 || !scopeReady || 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={scopeReady ? scopedProfiles : []}
columns={columns}
initialFit="container"
getRowKey={(profile) => profile.id}
emptyText={scopeReady ? "No profiles in this scope." : `Select a ${targetLabel.toLowerCase()} first.`}
/>
</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 draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} />
</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 [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
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);
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);
} catch (err) {
setPolicy(blankPolicy);
setEffectivePolicy(null);
setError(errorMessage(err));
} finally {
setLoading(false);
}
}
async function savePolicy() {
if (!scopeReady) return;
setBusy(true);
setError("");
setSuccess("");
try {
const response = await updateMailProfilePolicy(settings, scopeType, normalizePolicy(policy), scopeId);
setPolicy(normalizePolicy(response.policy));
setEffectivePolicy(response.effective_policy ? normalizePolicy(response.effective_policy) : null);
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 selectedProfileIds = new Set(policy.allowed_profile_ids ?? []);
const disabled = locked || busy || loading || !canWrite || !scopeReady;
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 });
}
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>
<Button onClick={() => patchPolicy({ allowed_profile_ids: [] })} disabled={disabled || selectedProfileIds.size === 0}>Clear allow-list</Button>
</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} 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>
</section>
<section className="mail-policy-section">
<h3>Lower-level profile definitions</h3>
<div className="admin-form-grid three-columns mail-policy-flags">
<PolicyFlagSelect label="User profiles" value={booleanToFlag(policy.allow_user_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_user_profiles", value)} />
<PolicyFlagSelect label="Group profiles" value={booleanToFlag(policy.allow_group_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_group_profiles", value)} />
<PolicyFlagSelect label="Campaign profiles" value={booleanToFlag(policy.allow_campaign_profiles)} disabled={disabled} onChange={(value) => setFlag("allow_campaign_profiles", value)} />
</div>
</section>
<section className="mail-policy-section">
<h3>Credential inheritance</h3>
<div className="admin-form-grid two-columns mail-policy-flags">
<CredentialInheritanceSelect label="SMTP credentials" value={credentialInheritanceToValue(policy.smtp_credentials?.inherit)} disabled={disabled} onChange={(value) => setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} />
<CredentialOverrideSelect label="SMTP override" value={credentialOverrideToValue(policy.smtp_credentials?.allow_override)} disabled={disabled} onChange={(value) => setCredentialPolicy("smtp", { allow_override: valueToCredentialOverride(value) })} />
<CredentialInheritanceSelect label="IMAP credentials" value={credentialInheritanceToValue(policy.imap_credentials?.inherit)} disabled={disabled} onChange={(value) => setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} />
<CredentialOverrideSelect label="IMAP override" value={credentialOverrideToValue(policy.imap_credentials?.allow_override)} disabled={disabled} onChange={(value) => setCredentialPolicy("imap", { allow_override: valueToCredentialOverride(value) })} />
</div>
</section>
<section className="mail-policy-section">
<h3>Wildcard rules</h3>
<div className="mail-policy-pattern-grid">
<div>
<h4>Whitelist</h4>
{mailProfilePatternKeys.map((key) => <PatternTextarea key={`whitelist-${key}`} label={patternLabels[key]} value={patternsToText(policy.whitelist?.[key])} disabled={disabled} onChange={(text) => setPattern("whitelist", key, text)} />)}
</div>
<div>
<h4>Blacklist</h4>
{mailProfilePatternKeys.map((key) => <PatternTextarea key={`blacklist-${key}`} label={patternLabels[key]} value={patternsToText(policy.blacklist?.[key])} disabled={disabled} onChange={(text) => setPattern("blacklist", key, text)} />)}
</div>
</div>
</section>
{effectivePolicy && (
<section className="mail-policy-section mail-policy-effective">
<h3>Effective campaign policy</h3>
<div className="mail-policy-effective-grid">
<div><span>Profiles</span><strong>{effectiveProfileLabel(effectivePolicy.allowed_profile_ids)}</strong></div>
<div><span>User profiles</span><strong>{effectivePolicy.allow_user_profiles ? "Allowed" : "Blocked"}</strong></div>
<div><span>Group profiles</span><strong>{effectivePolicy.allow_group_profiles ? "Allowed" : "Blocked"}</strong></div>
<div><span>Campaign profiles</span><strong>{effectivePolicy.allow_campaign_profiles ? "Allowed" : "Blocked"}</strong></div>
<div><span>SMTP credentials</span><strong>{credentialPolicyLabel(effectivePolicy.smtp_credentials)}</strong></div>
<div><span>IMAP credentials</span><strong>{credentialPolicyLabel(effectivePolicy.imap_credentials)}</strong></div>
</div>
</section>
)}
</div>
</Card>
);
}
function ProfileForm({
draft,
setDraft,
editing,
busy,
canWrite,
canManageCredentials
}: {
draft: ProfileDraft;
setDraft: (draft: ProfileDraft) => void;
editing: EditingProfile;
busy: boolean;
canWrite: boolean;
canManageCredentials: boolean;
}) {
const disabled = busy || !canWrite;
const credentialDisabled = disabled || !canManageCredentials;
const existingHasImap = editing !== "new" && Boolean(editing?.imap);
const imapToggleDisabled = disabled || (!canManageCredentials && existingHasImap && draft.imapEnabled);
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>
<h3>SMTP</h3>
<div className="admin-form-grid three-columns">
<FormField label="Host"><input value={draft.smtpHost} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpHost: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.smtpPort} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpPort: event.target.value })} /></FormField>
<FormField label="Security"><SecuritySelect value={draft.smtpSecurity} disabled={disabled} onChange={(smtpSecurity) => setDraft({ ...draft, smtpSecurity })} /></FormField>
<FormField label="Username"><input value={draft.smtpUsername} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpUsername: event.target.value })} /></FormField>
<FormField label="Password" help={editing === "new" ? undefined : "Leave empty to keep the current password."}><input type="password" value={draft.smtpPassword} disabled={credentialDisabled} onChange={(event) => setDraft({ ...draft, smtpPassword: event.target.value })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.smtpTimeout} disabled={disabled} onChange={(event) => setDraft({ ...draft, smtpTimeout: event.target.value })} /></FormField>
</div>
<div className="mail-profile-imap-heading">
<h3>IMAP</h3>
<ToggleSwitch label="Enable IMAP" checked={draft.imapEnabled} disabled={imapToggleDisabled} onChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })} />
</div>
<div className="admin-form-grid three-columns">
<FormField label="Host"><input value={draft.imapHost} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapHost: event.target.value })} /></FormField>
<FormField label="Port"><input type="number" min={1} max={65535} value={draft.imapPort} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapPort: event.target.value })} /></FormField>
<FormField label="Security"><SecuritySelect value={draft.imapSecurity} disabled={disabled || !draft.imapEnabled} onChange={(imapSecurity) => setDraft({ ...draft, imapSecurity })} /></FormField>
<FormField label="Username"><input value={draft.imapUsername} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapUsername: event.target.value })} /></FormField>
<FormField label="Password" help={editing === "new" ? undefined : "Leave empty to keep the current password."}><input type="password" value={draft.imapPassword} disabled={credentialDisabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapPassword: event.target.value })} /></FormField>
<FormField label="Sent folder"><input value={draft.imapSentFolder} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapSentFolder: event.target.value })} /></FormField>
<FormField label="Timeout seconds"><input type="number" min={1} value={draft.imapTimeout} disabled={disabled || !draft.imapEnabled} onChange={(event) => setDraft({ ...draft, imapTimeout: event.target.value })} /></FormField>
</div>
</div>
);
}
function PolicyFlagSelect({ label, value, disabled, onChange }: { label: string; value: PolicyFlagValue; disabled: boolean; onChange: (value: PolicyFlagValue) => void }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as PolicyFlagValue)}>
<option value="inherit">Inherit</option>
<option value="allow">Explicit allow</option>
<option value="deny">Deny</option>
</select>
</FormField>
);
}
function CredentialInheritanceSelect({ label, value, disabled, onChange }: { label: string; value: CredentialInheritanceValue; disabled: boolean; onChange: (value: CredentialInheritanceValue) => void }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as CredentialInheritanceValue)}>
<option value="inherit">Inherit parent decision</option>
<option value="profile">Inherit profile credentials</option>
<option value="local">Require local credentials</option>
</select>
</FormField>
);
}
function CredentialOverrideSelect({ label, value, disabled, onChange }: { label: string; value: CredentialOverrideValue; disabled: boolean; onChange: (value: CredentialOverrideValue) => void }) {
return (
<FormField label={label}>
<select value={value} disabled={disabled} onChange={(event) => onChange(event.target.value as CredentialOverrideValue)}>
<option value="inherit">Inherit</option>
<option value="allow">Allow lower override</option>
<option value="lock">Lock lower levels</option>
</select>
</FormField>
);
}
function PatternTextarea({ label, value, disabled, onChange }: { label: string; value: string; disabled: boolean; onChange: (value: string) => void }) {
return <FormField label={label}><textarea rows={3} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} placeholder="*.example.org" /></FormField>;
}
function SecuritySelect({ value, disabled, onChange }: { value: MailSecurity; disabled: boolean; onChange: (value: MailSecurity) => void }) {
return <select value={value} disabled={disabled} onChange={(event) => onChange(readSecurity(event.target.value, value))}>{securityOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select>;
}
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",
imapEnabled: false,
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.smtp.username),
smtpPassword: "",
smtpTimeout: stringValue(profile.smtp.timeout_seconds ?? 30),
imapEnabled: Boolean(profile.imap),
imapHost: stringValue(profile.imap?.host),
imapPort: stringValue(profile.imap?.port ?? 993),
imapSecurity: readSecurity(String(profile.imap?.security || "tls"), "tls"),
imapUsername: stringValue(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: emptyToNull(draft.slug),
description: emptyToNull(draft.description),
is_active: draft.isActive,
scope_type: scopeType,
scope_id: scopeId,
smtp: smtpPayload(draft, false),
imap: draft.imapEnabled ? imapPayload(draft, false) : null
};
}
function updateProfilePayload(draft: ProfileDraft, profile: MailServerProfile): MailServerProfileUpdatePayload {
return {
name: draft.name.trim(),
slug: emptyToNull(draft.slug),
description: draft.description.trim(),
is_active: draft.isActive,
smtp: smtpPayload(draft, true),
imap: draft.imapEnabled ? imapPayload(draft, true) : null,
clear_imap: !draft.imapEnabled && Boolean(profile.imap)
};
}
function smtpPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailSmtpTestPayload {
const payload: MailSmtpTestPayload = {
host: emptyToNull(draft.smtpHost),
port: numberOrNull(draft.smtpPort),
username: emptyToNull(draft.smtpUsername),
security: draft.smtpSecurity,
timeout_seconds: numberOrDefault(draft.smtpTimeout, 30)
};
if (draft.smtpPassword || !preserveBlankPassword) payload.password = emptyToNull(draft.smtpPassword, false);
return payload;
}
function imapPayload(draft: ProfileDraft, preserveBlankPassword: boolean): MailImapTestPayload {
const payload: MailImapTestPayload = {
enabled: true,
host: emptyToNull(draft.imapHost),
port: numberOrNull(draft.imapPort),
username: emptyToNull(draft.imapUsername),
security: draft.imapSecurity,
sent_folder: emptyToNull(draft.imapSentFolder) || "auto",
timeout_seconds: numberOrDefault(draft.imapTimeout, 30)
};
if (draft.imapPassword || !preserveBlankPassword) payload.password = emptyToNull(draft.imapPassword, false);
return payload;
}
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)
};
}
function normalizeCredentialPolicy(value: MailCredentialPolicy | null | undefined): MailCredentialPolicy {
return {
inherit: typeof value?.inherit === "boolean" ? value.inherit : null,
allow_override: typeof value?.allow_override === "boolean" ? value.allow_override : 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 credentialOverrideToValue(value: boolean | null | undefined): CredentialOverrideValue {
if (value === true) return "allow";
if (value === false) return "lock";
return "inherit";
}
function valueToCredentialOverride(value: CredentialOverrideValue): boolean | null {
if (value === "allow") return true;
if (value === "lock") return false;
return null;
}
function credentialPolicyLabel(value: MailCredentialPolicy | null | undefined): string {
const source = value?.inherit === false ? "Local required" : "Profile inherited";
const override = value?.allow_override === false ? "locked" : "override allowed";
return `${source}, ${override}`;
}
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 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 securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
}
function emptyToNull(value: string, trim = true): string | null {
const normalized = trim ? value.trim() : value;
return normalized ? normalized : null;
}
function stringValue(value: unknown): string {
if (value === null || value === undefined) return "";
return String(value);
}
function numberOrNull(value: string): number | null {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
function numberOrDefault(value: string, fallback: number): number {
const parsed = numberOrNull(value);
return parsed ?? fallback;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

7
webui/src/index.ts Normal file
View File

@@ -0,0 +1,7 @@
import "./styles/mail-profiles.css";
export { default } from "./module";
export * from "./module";
export * from "./api/mail";
export { MailProfilePolicyEditor, MailProfileScopeManager } from "./features/mail/MailProfileManagement";
export type { MailProfileTargetOption } from "./features/mail/MailProfileManagement";
export type { PlatformWebModule, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext } from "@govoplan/core-webui";

10
webui/src/module.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { PlatformWebModule } from "@govoplan/core-webui";
export const mailModule: PlatformWebModule = {
id: "mail",
label: "Mail",
version: "1.0.0",
dependencies: ["access"]
};
export default mailModule;

View File

@@ -0,0 +1,195 @@
.admin-form-grid.three-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.button-icon-label {
display: inline-flex;
align-items: center;
gap: 7px;
}
.mail-profile-manager {
display: grid;
gap: 18px;
}
.mail-profile-target-row {
max-width: 520px;
}
.mail-profile-table-surface {
width: 100%;
max-width: 100%;
min-width: 0;
}
.mail-profile-table-surface > .data-grid-shell {
width: 100%;
max-width: 100%;
min-width: 0;
}
.card-body > .mail-profile-table-surface:only-child {
margin: -22px -24px;
width: calc(100% + 48px);
max-width: inherit;
}
.card-body > .mail-profile-table-surface:only-child > .data-grid-shell {
border: 0;
border-radius: 0;
box-shadow: none;
}
.mail-profile-dialog .dialog-body {
max-height: min(76vh, 820px);
overflow: auto;
}
.mail-profile-form {
display: grid;
gap: 18px;
}
.mail-profile-form h3,
.mail-policy-section h3,
.mail-policy-pattern-grid h4 {
margin: 0;
color: var(--text-strong);
}
.mail-profile-imap-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding-top: 4px;
}
.mail-credential-inheritance-row {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: 12px;
max-width: 760px;
}
.mail-policy-editor {
display: grid;
gap: 18px;
}
.mail-policy-description {
margin: 0;
}
.mail-policy-section {
display: grid;
gap: 12px;
padding-top: 4px;
}
.mail-profile-checkbox-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.mail-profile-checkbox {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface);
}
.mail-profile-checkbox input {
width: auto;
margin-top: 3px;
box-shadow: none;
}
.mail-profile-checkbox span {
display: grid;
gap: 2px;
min-width: 0;
}
.mail-profile-checkbox small {
color: var(--muted);
overflow-wrap: anywhere;
}
.mail-policy-pattern-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.mail-policy-pattern-grid > div {
display: grid;
gap: 12px;
align-content: start;
}
.mail-policy-effective {
border-top: 1px solid var(--line);
padding-top: 14px;
}
.mail-policy-effective-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.mail-policy-effective-grid div {
display: grid;
gap: 3px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-subtle);
}
.mail-policy-effective-grid span {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.mail-policy-effective-grid strong {
color: var(--text-strong);
}
.status-badge.success {
background: var(--success-bg);
color: var(--success-text);
}
.status-badge.neutral {
background: #e7e4df;
color: #666;
}
@media (max-width: 900px) {
.admin-form-grid.three-columns,
.mail-policy-pattern-grid,
.mail-policy-effective-grid {
grid-template-columns: 1fr;
}
.mail-credential-inheritance-row {
grid-template-columns: 1fr;
}
.mail-profile-imap-heading {
align-items: flex-start;
flex-direction: column;
}
}

1
webui/src/types.ts Normal file
View File

@@ -0,0 +1 @@
export type { ApiSettings, AuthInfo, PlatformNavItem, PlatformRouteContribution, PlatformRouteContext, PlatformWebModule } from "@govoplan/core-webui";