first wokring prototype
This commit is contained in:
291
src/features/campaigns/MailSettingsPage.tsx
Normal file
291
src/features/campaigns/MailSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user