Prepare v0.1.0 release

This commit is contained in:
2026-06-24 20:01:21 +02:00
parent a147ad27ec
commit d75b64a7a1
10 changed files with 186 additions and 708 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { MailServerSettingsPanel, formatDateTime as formatPlatformDateTime, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
import { useEffect, useState } from "react";
import { MailServerSettingsPanel, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
@@ -8,33 +8,27 @@ import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import DismissibleAlert from "../../components/DismissibleAlert";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import { MailProfilePolicyEditor } from "../mail/MailProfileManagement";
import {
clearMockMailboxMessages,
createMailServerProfile,
getMailProfilePolicy,
getMockMailboxMessage,
listImapFolders,
listMailProfileImapFolders,
listMailServerProfiles,
listMockMailboxMessages,
testImapSettings,
testMailProfileImap,
testMailProfileSmtp,
testSmtpSettings,
updateMockMailboxFailures,
type MailProfilePolicy,
type MailSecurity,
type MailServerProfile,
type MockMailboxMessage
type MailServerProfile
} from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool, getNumber, getText } from "./utils/draftEditor";
import { campaignMailSettingsPolicyState } from "./policyUi";
const securityOptions = ["plain", "tls", "starttls"];
@@ -43,11 +37,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
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" | "mock" | null>(null);
const [mockMessages, setMockMessages] = useState<MockMailboxMessage[]>([]);
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
const [mockError, setMockError] = useState("");
const [mockSandboxEnabled, setMockSandboxEnabled] = useState(false);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const [mailProfiles, setMailProfiles] = useState<MailServerProfile[]>([]);
const [policyProfiles, setPolicyProfiles] = useState<MailServerProfile[]>([]);
const [effectiveMailPolicy, setEffectiveMailPolicy] = useState<MailProfilePolicy | null>(null);
@@ -55,7 +45,6 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
const [profileName, setProfileName] = useState("");
const [profileMessage, setProfileMessage] = useState("");
const [profileError, setProfileError] = useState("");
const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
@@ -83,11 +72,14 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
const effectiveImapAvailable = usingMailProfile ? selectedProfileHasImap : imapEnabled;
const smtpCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.smtp_credentials?.inherit !== false : false;
const imapCredentialsInherited = usingMailProfile ? effectiveMailPolicy?.imap_credentials?.inherit !== false : false;
const smtpDisabled = locked || usingMailProfile;
const smtpCredentialDisabled = locked || (usingMailProfile && smtpCredentialsInherited);
const imapServerDisabled = locked || usingMailProfile || !imapEnabled;
const imapCredentialDisabled = locked || !effectiveImapAvailable || (usingMailProfile && imapCredentialsInherited);
const imapDisabled = locked || !effectiveImapAvailable;
const mailPolicyState = campaignMailSettingsPolicyState({ effectivePolicy: effectiveMailPolicy, selectedProfileId, locked });
const campaignProfilesAllowed = mailPolicyState.campaignProfilesAllowed;
const inlineMailSettingsBlocked = mailPolicyState.inlineMailSettingsBlocked;
const smtpDisabled = locked || usingMailProfile || inlineMailSettingsBlocked;
const smtpCredentialDisabled = locked || inlineMailSettingsBlocked || (usingMailProfile && smtpCredentialsInherited);
const imapServerDisabled = locked || inlineMailSettingsBlocked || usingMailProfile || !imapEnabled;
const imapCredentialDisabled = locked || inlineMailSettingsBlocked || !effectiveImapAvailable || (usingMailProfile && imapCredentialsInherited);
const imapDisabled = locked || inlineMailSettingsBlocked || !effectiveImapAvailable;
useEffect(() => { void refreshMailProfiles(); }, [settings.apiBaseUrl, settings.apiKey, campaignId]);
@@ -115,13 +107,16 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
function selectMailProfile(profileId: string) {
if (locked) return;
if (!profileId && !campaignProfilesAllowed) {
setProfileError(mailPolicyState.inlineBlockedMessage);
return;
}
patch(["server", "mail_profile_id"], profileId);
setProfileMessage("");
setProfileError("");
if (profileId) {
patch(["server", "smtp"], {});
patch(["server", "imap"], {});
setMockSandboxEnabled(false);
setSmtpTestResult(null);
setImapTestResult(null);
setFolderResult(null);
@@ -129,7 +124,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
async function saveCurrentSettingsAsProfile() {
if (locked || usingMailProfile) return;
if (locked || usingMailProfile || !campaignProfilesAllowed) return;
setProfilesLoading(true);
setProfileMessage("");
setProfileError("");
@@ -248,7 +243,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
async function runSmtpTest() {
if (locked) return;
if (locked || inlineMailSettingsBlocked) return;
setMailActionState("smtp");
setLocalError("");
try {
@@ -303,109 +298,6 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
}
function toggleMockMailSandbox(enabled: boolean) {
if (locked || usingMailProfile) return;
setMockSandboxEnabled(enabled);
if (enabled) {
mockSandboxSnapshot.current = {
smtp: { ...smtp },
imap: { ...imap },
imap_append_sent: { ...imapAppend }
};
patch(["server", "smtp", "host"], "mock.smtp.local");
patch(["server", "smtp", "port"], 2525);
patch(["server", "smtp", "security"], "plain");
patch(["server", "smtp", "username"], "mock");
patch(["server", "smtp", "password"], "mock");
patch(["server", "imap", "enabled"], true);
patch(["server", "imap", "host"], "mock.imap.local");
patch(["server", "imap", "port"], 1143);
patch(["server", "imap", "security"], "plain");
patch(["server", "imap", "username"], "mock");
patch(["server", "imap", "password"], "mock");
patch(["server", "imap", "sent_folder"], "Sent");
patch(["delivery", "imap_append_sent", "enabled"], true);
patch(["delivery", "imap_append_sent", "folder"], "Sent");
setSmtpTestResult({ ok: true, protocol: "smtp", host: "mock.smtp.local", port: 2525, security: "plain", message: "Temporary mock profile enabled. Turn it off to restore the previous values.", details: { mock: true } });
return;
}
const snapshot = mockSandboxSnapshot.current;
if (snapshot) {
patch(["server", "smtp"], asRecord(snapshot.smtp));
patch(["server", "imap"], asRecord(snapshot.imap));
patch(["delivery", "imap_append_sent"], asRecord(snapshot.imap_append_sent));
}
mockSandboxSnapshot.current = null;
setSmtpTestResult(null);
setImapTestResult(null);
}
async function loadMockMailbox() {
setMailActionState("mock");
setMockError("");
try {
const response = await listMockMailboxMessages(settings);
setMockMessages(response.messages);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function openMockMessage(id: string) {
setMailActionState("mock");
setMockError("");
try {
const response = await getMockMailboxMessage(settings, id);
setSelectedMockMessage(response.message);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function clearMockMailbox() {
setMailActionState("mock");
setMockError("");
try {
await clearMockMailboxMessages(settings);
setMockMessages([]);
setSelectedMockMessage(null);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function failNextMockSmtp() {
setMailActionState("mock");
setMockError("");
try {
await updateMockMailboxFailures(settings, { fail_next_smtp: true });
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function failNextMockImap() {
setMailActionState("mock");
setMockError("");
try {
await updateMockMailboxFailures(settings, { fail_next_imap: true });
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
@@ -415,7 +307,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading}>Reload</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft}>{dirty ? "Save now" : "Saved"}</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || !draft || inlineMailSettingsBlocked}>{dirty ? "Save now" : "Saved"}</Button>
</div>
</div>
@@ -435,8 +327,8 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
ownerGroupId={data.campaign?.owner_group_id}
canWrite={!locked}
locked={locked}
title="Campaign mail profile policy"
description="Campaign-level allow-list and wildcard limits applied after system, tenant and owner policies."
title="Campaign-local mail policy"
description="Campaign-local mail limits applied after system, tenant and owner policies."
onSaved={refreshMailProfiles}
/>
@@ -445,21 +337,22 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
actions={
<div className="button-row compact-actions">
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "Loading…" : "Reload profiles"}</Button>
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button>
<Button variant="primary" onClick={() => void saveCurrentSettingsAsProfile()} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed}>{profilesLoading ? "Saving…" : "Save current settings as profile"}</Button>
</div>
}
>
<div className="form-grid compact responsive-form-grid">
<FormField label="Profile">
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
<option value="">Inline SMTP/IMAP settings</option>
<option value="" disabled={mailPolicyState.inlineOptionDisabled}>Inline SMTP/IMAP settings</option>
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
</select>
</FormField>
<FormField label="New profile name">
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} mail profile` : "Campaign mail profile"} />
<input value={profileName} disabled={locked || usingMailProfile || profilesLoading || !campaignProfilesAllowed} onChange={(event) => setProfileName(event.target.value)} placeholder={data.campaign?.name ? `${data.campaign.name} campaign-local profile` : "Campaign-local profile"} />
</FormField>
</div>
{!campaignProfilesAllowed && <p className="muted small-note">Campaign-local mail settings are blocked by the effective mail policy. Select an allowed reusable profile.</p>}
{selectedProfile && (
<p className="muted small-note">Using {selectedProfile.name} ({profileScopeLabel(selectedProfile)}). SMTP credentials: {smtpCredentialsInherited ? "inherited from profile" : "local credentials required"}. IMAP credentials: {selectedProfile.imap ? (imapCredentialsInherited ? "inherited from profile" : "local credentials required") : "not configured"}.</p>
)}
@@ -492,12 +385,11 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
onImapEnabledChange={toggleImap}
smtpDisabled={smtpDisabled}
smtpCredentialDisabled={smtpCredentialDisabled}
smtpActionDisabled={locked}
imapToggleDisabled={locked || usingMailProfile}
smtpActionDisabled={locked || inlineMailSettingsBlocked}
imapToggleDisabled={locked || usingMailProfile || inlineMailSettingsBlocked}
imapServerDisabled={imapServerDisabled}
imapCredentialDisabled={imapCredentialDisabled}
imapActionDisabled={imapDisabled}
mockToggle={{ checked: mockSandboxEnabled, disabled: locked || usingMailProfile, onChange: toggleMockMailSandbox }}
append={{
enabled: getBool(imapAppend, "enabled"),
folder: getText(imapAppend, "folder", getText(imap, "sent_folder", "auto")),
@@ -521,88 +413,12 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
/>
</Card>
<Card title="Mock mail sandbox">
<div className="subsection-heading split">
<div>
<h3>Captured messages</h3>
<p className="muted small-note">Use the mock sandbox profile, save this page, then send normally. SMTP deliveries and IMAP Sent appends appear here.</p>
</div>
<div className="button-row compact-actions">
<Button onClick={loadMockMailbox} disabled={mailActionState === "mock"}>{mailActionState === "mock" ? "Loading…" : "Reload mailbox"}</Button>
<Button onClick={failNextMockSmtp} disabled={mailActionState === "mock"}>Fail next SMTP</Button>
<Button onClick={failNextMockImap} disabled={mailActionState === "mock"}>Fail next IMAP</Button>
<Button variant="danger" onClick={clearMockMailbox} disabled={mailActionState === "mock" || mockMessages.length === 0}>Clear</Button>
</div>
</div>
{mockError && <DismissibleAlert tone="danger" resetKey={mockError} floating>{mockError}</DismissibleAlert>}
<DataGrid
id={`campaign-${campaignId}-server-mock-mailbox`}
rows={mockMessages}
columns={mockMailboxColumns(openMockMessage)}
getRowKey={(message) => message.id}
emptyText="No mock messages captured yet."
className="data-table compact-table"
/>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked || inlineMailSettingsBlocked}>Save</Button>
</div>
</>
</LoadingFrame>
{selectedMockMessage && (
<MessagePreviewOverlay
title="Captured mock mail"
subject={selectedMockMessage.subject || "Mock message"}
bodyMode="text"
text={selectedMockMessage.body_preview || ""}
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
recipientNote={selectedMockMessage.created_at ? formatPlatformDateTime(selectedMockMessage.created_at) : undefined}
metaItems={mockMessageMetaItems(selectedMockMessage)}
attachments={mockMessageAttachments(selectedMockMessage)}
raw={selectedMockMessage.raw_eml}
rawLabel="Raw MIME"
onClose={() => setSelectedMockMessage(null)}
/>
)}
</div>
);
}
function mockMessageMetaItems(message: MockMailboxMessage) {
return [
{ label: "From", value: message.from_header || message.envelope_from || "—" },
{ label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
{ label: "Kind", value: message.kind || "—" },
{ label: "Folder", value: message.folder || "—" },
{ label: "Message-ID", value: message.message_id || "—" },
{ label: "Size", value: `${message.size_bytes || 0} bytes` }
];
}
function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] {
return (message.attachments ?? []).map((attachment, index) => ({
filename: attachment.filename || `Attachment ${index + 1}`,
contentType: attachment.content_type || undefined,
sizeBytes: attachment.size_bytes ?? undefined
}));
}
function formatMockDate(value: string): string {
return formatPlatformDateTime(value);
}
function mockMailboxColumns(openMockMessage: (id: string) => Promise<void>): DataGridColumn<MockMailboxMessage>[] {
return [
{ id: "kind", header: "Kind", width: 110, sortable: true, filterable: true, sticky: "start", columnType: "from-list", list: { options: [{ value: "SMTP", label: "SMTP" }, { value: "IMAP", label: "IMAP" }] }, render: (message) => <span className="status-badge neutral">{message.kind === "imap_append" ? "IMAP" : "SMTP"}</span>, value: (message) => message.kind === "imap_append" ? "IMAP" : "SMTP" },
{ id: "received", header: "Received", width: 180, sortable: true, filterable: true, filterType: "date", value: (message) => formatMockDate(message.created_at), sortValue: (message) => message.created_at ?? "" },
{ id: "subject", header: "Subject", width: "minmax(240px, 1fr)", sortable: true, filterable: true, value: (message) => message.subject || "—" },
{ id: "envelope", header: "Envelope", width: 300, resizable: true, filterable: true, value: (message) => `${message.envelope_from || message.folder || "—"}${(message.envelope_recipients || []).join(", ") || message.folder || "—"}` },
{ id: "attachments", header: "Attachments", width: 130, sortable: true, filterable: true, filterType: "integer", value: (message) => message.attachment_count ?? 0 },
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (message) => <Button onClick={() => openMockMessage(message.id)}>Open</Button> }
];
}