campaign sending prototype

This commit is contained in:
2026-06-24 16:20:44 +02:00
parent 99fee44651
commit a9d16a2c89
12 changed files with 1478 additions and 41 deletions

View File

@@ -1,13 +1,20 @@
import { useEffect, useMemo, useState } from "react";
import { MailServerSettingsPanel, type MailServerConnectionTestResult, type MailServerFolderLookupResult, type MailServerImapSettings, type MailServerSmtpSettings } from "@govoplan/core-webui";
import { Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
createMailServerProfile,
deactivateMailServerProfile,
getMailProfilePolicy,
listImapFolders,
listMailProfileImapFolders,
listMailServerProfiles,
mailProfilePatternKeys,
updateMailProfilePolicy,
testImapSettings,
testMailProfileImap,
testMailProfileSmtp,
testSmtpSettings,
updateMailServerProfile,
type MailCredentialPolicy,
type MailImapTestPayload,
@@ -28,7 +35,6 @@ 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;
@@ -290,7 +296,7 @@ export function MailProfileScopeManager({
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} />
<ProfileForm settings={settings} draft={draft} setDraft={setDraft} editing={editing} busy={busy} canWrite={canWriteProfiles} canManageCredentials={canManageCredentials} />
</Dialog>
<ConfirmDialog
@@ -492,6 +498,7 @@ export function MailProfilePolicyEditor({
}
function ProfileForm({
settings,
draft,
setDraft,
editing,
@@ -499,6 +506,7 @@ function ProfileForm({
canWrite,
canManageCredentials
}: {
settings: ApiSettings;
draft: ProfileDraft;
setDraft: (draft: ProfileDraft) => void;
editing: EditingProfile;
@@ -506,10 +514,101 @@ function ProfileForm({
canWrite: boolean;
canManageCredentials: boolean;
}) {
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" | null>(null);
const disabled = busy || !canWrite;
const credentialDisabled = disabled || !canManageCredentials;
const existingHasImap = editing !== "new" && Boolean(editing?.imap);
const existingProfile = editing !== "new" ? editing : null;
const existingHasImap = Boolean(existingProfile?.imap);
const imapToggleDisabled = disabled || (!canManageCredentials && existingHasImap && draft.imapEnabled);
const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured);
const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured);
useEffect(() => {
setSmtpTestResult(null);
setImapTestResult(null);
setFolderResult(null);
setMailActionState(null);
}, [editing]);
function patchSmtpSettings(patch: Partial<MailServerSmtpSettings>) {
setDraft({
...draft,
smtpHost: patch.host !== undefined ? String(patch.host ?? "") : draft.smtpHost,
smtpPort: patch.port !== undefined ? String(patch.port ?? "") : draft.smtpPort,
smtpUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.smtpUsername,
smtpPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.smtpPassword,
smtpSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "starttls"), "starttls") : draft.smtpSecurity,
smtpTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.smtpTimeout
});
}
function patchImapSettings(patch: Partial<MailServerImapSettings>) {
setDraft({
...draft,
imapHost: patch.host !== undefined ? String(patch.host ?? "") : draft.imapHost,
imapPort: patch.port !== undefined ? String(patch.port ?? "") : draft.imapPort,
imapUsername: patch.username !== undefined ? String(patch.username ?? "") : draft.imapUsername,
imapPassword: patch.password !== undefined ? String(patch.password ?? "") : draft.imapPassword,
imapSecurity: patch.security !== undefined ? readSecurity(String(patch.security || "tls"), "tls") : draft.imapSecurity,
imapSentFolder: patch.sent_folder !== undefined ? String(patch.sent_folder ?? "") : draft.imapSentFolder,
imapTimeout: patch.timeout_seconds !== undefined ? String(patch.timeout_seconds ?? "") : draft.imapTimeout
});
}
async function runSmtpTest() {
setMailActionState("smtp");
setSmtpTestResult(null);
try {
setSmtpTestResult(useSavedSmtpTest && existingProfile
? await testMailProfileSmtp(settings, existingProfile.id)
: await testSmtpSettings(settings, smtpPayload(draft, false)));
} catch (err) {
setSmtpTestResult({ ok: false, protocol: "smtp", message: errorMessage(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runImapTest() {
if (!draft.imapEnabled) return;
setMailActionState("imap");
setImapTestResult(null);
try {
setImapTestResult(useSavedImapTest && existingProfile
? await testMailProfileImap(settings, existingProfile.id)
: await testImapSettings(settings, imapPayload(draft, false)));
} catch (err) {
setImapTestResult({ ok: false, protocol: "imap", message: errorMessage(err), details: {} });
} finally {
setMailActionState(null);
}
}
async function runFolderLookup() {
if (!draft.imapEnabled) return;
setMailActionState("folders");
setFolderResult(null);
try {
setFolderResult(useSavedImapTest && existingProfile
? await listMailProfileImapFolders(settings, existingProfile.id)
: await listImapFolders(settings, imapPayload(draft, false)));
} catch (err) {
setFolderResult({ ok: false, message: errorMessage(err), folders: [] });
} finally {
setMailActionState(null);
}
}
function useDetectedSentFolder() {
const folder = folderResult?.detected_sent_folder;
if (!folder || disabled || !draft.imapEnabled) return;
setDraft({ ...draft, imapSentFolder: folder });
}
return (
<div className="mail-profile-form">
<div className="admin-form-grid two-columns">
@@ -519,29 +618,32 @@ function ProfileForm({
<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>
<MailServerSettingsPanel
smtp={{ host: draft.smtpHost, port: draft.smtpPort, username: draft.smtpUsername, password: draft.smtpPassword, security: draft.smtpSecurity, timeout_seconds: draft.smtpTimeout }}
imap={{ enabled: draft.imapEnabled, host: draft.imapHost, port: draft.imapPort, username: draft.imapUsername, password: draft.imapPassword, security: draft.imapSecurity, sent_folder: draft.imapSentFolder, timeout_seconds: draft.imapTimeout }}
onSmtpChange={patchSmtpSettings}
onImapChange={patchImapSettings}
onImapEnabledChange={(imapEnabled) => setDraft({ ...draft, imapEnabled })}
smtpDisabled={disabled}
smtpCredentialDisabled={credentialDisabled}
imapToggleDisabled={imapToggleDisabled}
imapServerDisabled={disabled || !draft.imapEnabled}
imapCredentialDisabled={credentialDisabled || !draft.imapEnabled}
imapActionDisabled={disabled || !draft.imapEnabled}
smtpTitle="SMTP"
imapTitle="IMAP"
smtpTestLabel={useSavedSmtpTest ? "Test saved SMTP" : "Test SMTP"}
imapTestLabel={useSavedImapTest ? "Test saved IMAP" : "Test IMAP"}
busyAction={mailActionState}
onTestSmtp={() => void runSmtpTest()}
onTestImap={() => void runImapTest()}
onLookupFolders={() => void runFolderLookup()}
smtpTestResult={smtpTestResult}
imapTestResult={imapTestResult}
folderLookupResult={folderResult}
onUseDetectedFolder={useDetectedSentFolder}
useDetectedFolderDisabled={disabled || !draft.imapEnabled}
/>
</div>
);
}
@@ -586,10 +688,6 @@ function PatternTextarea({ label, value, disabled, onChange }: { label: string;
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>;