251 lines
13 KiB
TypeScript
251 lines
13 KiB
TypeScript
import { 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 LoadingFrame from "../../components/LoadingFrame";
|
|
import ToggleSwitch from "../../components/ToggleSwitch";
|
|
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
|
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
|
|
import { asRecord, isAuditLockedVersion, versionLockReason } from "./utils/campaignView";
|
|
import { getBool, getNumber, getText } from "./utils/draftEditor";
|
|
|
|
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 [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 version = data.currentVersion;
|
|
const locked = isAuditLockedVersion(version);
|
|
const { draft, displayDraft, dirty, saveState, localError, setLocalError, patch, saveDraft } = useCampaignDraftEditor({
|
|
settings,
|
|
campaignId,
|
|
version,
|
|
locked,
|
|
reload,
|
|
setError,
|
|
currentStep: "mail-settings",
|
|
unsavedTitle: "Unsaved server settings",
|
|
unsavedMessage: "Server settings have unsaved changes. Save them before leaving, or discard them and continue."
|
|
});
|
|
const server = asRecord(displayDraft.server);
|
|
const smtp = asRecord(server.smtp);
|
|
const imap = asRecord(server.imap);
|
|
const delivery = asRecord(displayDraft.delivery);
|
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
|
const imapEnabled = getBool(imap, "enabled");
|
|
const imapDisabled = locked || !imapEnabled;
|
|
|
|
|
|
|
|
|
|
function toggleImap(enabled: boolean) {
|
|
patch(["server", "imap", "enabled"], enabled);
|
|
if (!enabled) {
|
|
patch(["delivery", "imap_append_sent", "enabled"], false);
|
|
}
|
|
}
|
|
|
|
|
|
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>}
|
|
|
|
<LoadingFrame loading={loading || !draft} label="Loading campaign 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>
|
|
</>
|
|
</LoadingFrame>
|
|
</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>
|
|
);
|
|
} |