first wokring prototype

This commit is contained in:
2026-06-10 04:10:02 +02:00
parent 50d779a537
commit 7491c0a1b4
90 changed files with 10799 additions and 1 deletions

View File

@@ -0,0 +1,291 @@
import { useEffect, useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
import PageTitle from "../../components/PageTitle";
import ToggleSwitch from "../../components/ToggleSwitch";
import { autosaveCampaignVersion } from "../../api/campaigns";
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, getCampaignJson, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
import { ensureCampaignDraft, getBool, getNumber, getText, updateNested } from "./utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "./context/UnsavedChangesContext";
const securityOptions = ["plain", "tls", "starttls"];
export default function MailSettingsPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("Loaded");
const [localError, setLocalError] = useState("");
const [smtpTestResult, setSmtpTestResult] = useState<MailConnectionTestResponse | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailConnectionTestResponse | null>(null);
const [folderResult, setFolderResult] = useState<MailImapFolderListResponse | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const loadedVersionId = useRef<string | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const server = asRecord(draft?.server);
const smtp = asRecord(server.smtp);
const imap = asRecord(server.imap);
const delivery = asRecord(draft?.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
const imapEnabled = getBool(imap, "enabled");
const imapDisabled = locked || !imapEnabled;
useEffect(() => {
if (!version) return;
if (loadedVersionId.current === version.id) return;
loadedVersionId.current = version.id;
setDraft(ensureCampaignDraft(version));
setDirty(false);
setSaveState(version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "Loaded");
}, [version]);
function patch(path: string[], value: unknown) {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
setDirty(true);
setLocalError("");
}
async function saveDraft(mode: "auto" | "manual" = "manual"): Promise<boolean> {
if (!draft || !version || locked) return false;
setSaveState("Saving…");
setError("");
setLocalError("");
try {
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draft,
current_flow: "manual",
current_step: "mail-settings",
workflow_state: "editing",
is_complete: false
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("Save failed");
return false;
}
}
function toggleImap(enabled: boolean) {
patch(["server", "imap", "enabled"], enabled);
if (!enabled) {
patch(["delivery", "imap_append_sent", "enabled"], false);
}
}
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
title: "Unsaved server settings",
message: "Server settings have unsaved changes. Save them before leaving, or discard them and continue.",
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null);
function emptyToNull(value: string, trim = true): string | null {
const normalized = trim ? value.trim() : value;
return normalized ? normalized : null;
}
function readSecurity(value: string, fallback: MailSecurity): MailSecurity {
return securityOptions.includes(value as MailSecurity) ? (value as MailSecurity) : fallback;
}
function smtpPayload() {
return {
host: emptyToNull(getText(smtp, "host")),
port: getNumber(smtp, "port", 587),
username: emptyToNull(getText(smtp, "username")),
password: emptyToNull(getText(smtp, "password"), false),
security: readSecurity(getText(smtp, "security", "starttls"), "starttls"),
timeout_seconds: getNumber(smtp, "timeout_seconds", 30)
};
}
function imapPayload() {
return {
enabled: true,
host: emptyToNull(getText(imap, "host")),
port: getNumber(imap, "port", 993),
username: emptyToNull(getText(imap, "username")),
password: emptyToNull(getText(imap, "password"), false),
security: readSecurity(getText(imap, "security", "tls"), "tls"),
sent_folder: emptyToNull(getText(imap, "sent_folder", "auto")),
timeout_seconds: getNumber(imap, "timeout_seconds", 30)
};
}
async function runSmtpTest() {
if (locked) return;
setMailActionState("smtp");
setLocalError("");
try {
setSmtpTestResult(await testSmtpSettings(settings, smtpPayload()));
} catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runImapTest() {
if (imapDisabled) return;
setMailActionState("imap");
setLocalError("");
try {
setImapTestResult(await testImapSettings(settings, imapPayload()));
} catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runFolderLookup() {
if (imapDisabled) return;
setMailActionState("folders");
setLocalError("");
try {
setFolderResult(await listImapFolders(settings, imapPayload()));
} catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally {
setMailActionState(null);
}
}
function useDetectedSentFolder() {
const folder = folderResult?.detected_sent_folder;
if (!folder || imapDisabled) return;
patch(["server", "imap", "sent_folder"], folder);
if (!getText(imapAppend, "folder") || getText(imapAppend, "folder") === "auto") {
patch(["delivery", "imap_append_sent", "folder"], folder);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading}>Server settings</PageTitle>
<p className="mono-small">Version {version ? `#${version.version_number}` : "—"} · {saveState}</p>
</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>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{localError && <div className="alert danger">{localError}</div>}
{locked && <div className="alert info">This version is read-only. {versionLockReason(version)} Copy the campaign before editing server settings.</div>}
{draft && (
<>
<Card title="Mail server settings">
<div className="mail-server-settings-grid">
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>SMTP login</h3>
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Host"><input value={getText(smtp, "host")} disabled={locked} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(smtp, "port", 587)} disabled={locked} onChange={(event) => patch(["server", "smtp", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(smtp, "username")} disabled={locked} onChange={(event) => patch(["server", "smtp", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(smtp, "password")} disabled={locked} onChange={(event) => patch(["server", "smtp", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(smtp, "security", "starttls")} disabled={locked} onChange={(event) => patch(["server", "smtp", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Timeout seconds"><input type="number" value={getNumber(smtp, "timeout_seconds", 30)} disabled={locked} onChange={(event) => patch(["server", "smtp", "timeout_seconds"], Number(event.target.value || 0))} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runSmtpTest} disabled={locked || mailActionState === "smtp"}>{mailActionState === "smtp" ? "Testing…" : "Test SMTP login"}</Button>
</div>
<MailActionResult result={smtpTestResult} />
</section>
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>IMAP sent-folder append</h3>
</div>
<div className="form-grid compact responsive-form-grid">
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Enable IMAP" checked={imapEnabled} disabled={locked} onChange={toggleImap} />
</div>
<FormField label="Host"><input value={getText(imap, "host")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "host"], event.target.value)} /></FormField>
<FormField label="Port"><input type="number" value={getNumber(imap, "port", 993)} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "port"], Number(event.target.value || 0))} /></FormField>
<FormField label="Username"><input value={getText(imap, "username")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "username"], event.target.value)} /></FormField>
<FormField label="Password"><input type="password" value={getText(imap, "password")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "password"], event.target.value)} /></FormField>
<FormField label="Security"><select value={getText(imap, "security", "tls")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "security"], event.target.value)}>{securityOptions.map((option) => <option key={option}>{option}</option>)}</select></FormField>
<FormField label="Detected/saved sent folder"><input value={getText(imap, "sent_folder", "auto")} disabled={imapDisabled} onChange={(event) => patch(["server", "imap", "sent_folder"], event.target.value)} /></FormField>
<div className="form-span-full toggle-span-full">
<ToggleSwitch label="Append successfully sent messages to Sent" checked={getBool(imapAppend, "enabled")} disabled={imapDisabled} onChange={(checked) => patch(["delivery", "imap_append_sent", "enabled"], checked)} />
</div>
<FormField label="Append folder"><input value={getText(imapAppend, "folder", getText(imap, "sent_folder", "auto"))} disabled={imapDisabled || !getBool(imapAppend, "enabled")} onChange={(event) => patch(["delivery", "imap_append_sent", "folder"], event.target.value)} /></FormField>
</div>
<div className="button-row compact-actions subsection-bottom-actions">
<Button variant="primary" onClick={runImapTest} disabled={imapDisabled || mailActionState === "imap"}>{mailActionState === "imap" ? "Testing…" : "Test IMAP login"}</Button>
<Button variant="primary" onClick={runFolderLookup} disabled={imapDisabled || mailActionState === "folders"}>{mailActionState === "folders" ? "Looking up…" : "Folders…"}</Button>
</div>
<p className="muted small-note">Folder lookup lists visible mailboxes and guesses folders such as Sent, Gesendet or Sent Mail.</p>
<MailActionResult result={imapTestResult} />
<FolderLookupResult result={folderResult} disabled={imapDisabled} onUseDetected={useDetectedSentFolder} />
</section>
</div>
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
</>
)}
</div>
);
}
function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) {
if (!result) return null;
const authenticated = result.details?.authenticated;
return (
<div className={`alert ${result.ok ? "success" : "danger"}`}>
{result.message}
{result.ok && typeof authenticated === "boolean" && (
<span> Authentication: {authenticated ? "credentials accepted" : "not used"}.</span>
)}
</div>
);
}
function FolderLookupResult({ result, disabled, onUseDetected }: { result: MailImapFolderListResponse | null; disabled?: boolean; onUseDetected: () => void }) {
if (!result) return null;
if (!result.ok) {
return <div className="alert danger">{result.message}</div>;
}
return (
<div className="alert success">
<p>{result.message}</p>
<p>Detected Sent folder: <strong>{result.detected_sent_folder || "—"}</strong></p>
{result.detected_sent_folder && <Button onClick={onUseDetected} disabled={disabled}>Use detected folder</Button>}
{result.folders.length > 0 && (
<div className="field-chip-list">
{result.folders.slice(0, 12).map((folder) => (
<span className="field-chip" key={folder.name} title={(folder.flags || []).join(" ")}>{folder.name}</span>
))}
{result.folders.length > 12 && <span className="field-chip">+{result.folders.length - 12} more</span>}
</div>
)}
</div>
);
}