Integrate campaign delivery with hierarchical mail profiles

This commit is contained in:
2026-07-28 19:33:12 +02:00
parent 38af25ee88
commit 89ae14c032
13 changed files with 699 additions and 108 deletions
+281 -10
View File
@@ -2,21 +2,26 @@ import { useEffect, useState } from "react";
import {
Button,
Card,
Dialog,
DismissibleAlert,
FormField,
LoadingFrame,
MailServerFolderLookupResultView,
MetricCard,
PageTitle,
PasswordField,
ToggleSwitch,
usePlatformModuleInstalled,
usePlatformUiCapability,
type MailCredentialEnvelope,
type MailProfilesUiCapability,
type MailServerConnectionTestResult,
type MailServerEndpoint,
type MailServerFolderLookupResult
} from "@govoplan/core-webui";
import type { ApiSettings } from "../../types";
import {
createCampaignMailCredential,
listMailProfileImapFolders,
listMailServerProfiles,
testMailProfileImap,
@@ -39,6 +44,14 @@ type MailSettingsPageProps = {
view?: MailSettingsView;
};
type CampaignCredentialDraft = {
name: string;
username: string;
password: string;
useSmtp: boolean;
useImap: boolean;
};
export default function MailSettingsPage({ settings, campaignId, view = "settings" }: MailSettingsPageProps) {
const mailModuleInstalled = usePlatformModuleInstalled("mail");
const mailProfilesUi = usePlatformUiCapability<MailProfilesUiCapability>("mail.profiles");
@@ -53,6 +66,15 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
const [smtpTestResult, setSmtpTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailServerConnectionTestResult | null>(null);
const [folderResult, setFolderResult] = useState<MailServerFolderLookupResult | null>(null);
const [credentialDialogOpen, setCredentialDialogOpen] = useState(false);
const [credentialSaving, setCredentialSaving] = useState(false);
const [credentialDraft, setCredentialDraft] = useState<CampaignCredentialDraft>({
name: "",
username: "",
password: "",
useSmtp: true,
useImap: false
});
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
@@ -78,10 +100,28 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
const server = asRecord(displayDraft.server);
const selectedProfileId = getText(server, "mail_profile_id");
const selectedProfile = mailProfiles.find((profile) => profile.id === selectedProfileId) ?? null;
const smtpServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "smtp" && item.is_active);
const imapServers = (selectedProfile?.servers ?? []).filter((item) => item.protocol === "imap" && item.is_active);
const selectedSmtpServer = smtpServers.find((item) => item.id === getText(server, "smtp_server_id"))
?? smtpServers.find((item) => item.is_default)
?? smtpServers[0]
?? null;
const selectedImapServer = imapServers.find((item) => item.id === getText(server, "imap_server_id"))
?? imapServers.find((item) => item.is_default)
?? imapServers[0]
?? null;
const selectedSmtpCredential = selectedSmtpServer?.credentials.find((item) => item.id === getText(server, "smtp_credential_id"))
?? selectedSmtpServer?.credentials.find((item) => item.is_default)
?? selectedSmtpServer?.credentials[0]
?? null;
const selectedImapCredential = selectedImapServer?.credentials.find((item) => item.id === getText(server, "imap_credential_id"))
?? selectedImapServer?.credentials.find((item) => item.is_default)
?? selectedImapServer?.credentials[0]
?? null;
const delivery = asRecord(displayDraft.delivery);
const imapAppend = asRecord(delivery.imap_append_sent);
const imapAppendEnabled = getBool(imapAppend, "enabled");
const selectedProfileHasImap = Boolean(selectedProfile?.imap);
const selectedProfileHasImap = Boolean(selectedImapServer);
const selectedProfileUnavailable = Boolean(selectedProfileId && !profilesLoading && !selectedProfile);
const canSave = dirty && !locked && Boolean(draft) && (!migrationRequired || Boolean(selectedProfileId));
@@ -117,22 +157,140 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
function selectMailProfile(profileId: string) {
if (!mailModuleInstalled || locked) return;
patch(["server"], profileId ? { mail_profile_id: profileId } : {});
const profile = mailProfiles.find((item) => item.id === profileId);
const smtpServer = profile?.servers?.find((item) => item.protocol === "smtp" && item.is_active && item.is_default)
?? profile?.servers?.find((item) => item.protocol === "smtp" && item.is_active);
const imapServer = profile?.servers?.find((item) => item.protocol === "imap" && item.is_active && item.is_default)
?? profile?.servers?.find((item) => item.protocol === "imap" && item.is_active);
const smtpCredential = smtpServer?.credentials.find((item) => item.is_active && item.is_default)
?? smtpServer?.credentials.find((item) => item.is_active);
const imapCredential = imapServer?.credentials.find((item) => item.is_active && item.is_default)
?? imapServer?.credentials.find((item) => item.is_active);
patch(["server"], profile ? mailReference(
profile.id,
smtpServer?.id,
smtpCredential?.id,
imapServer?.id,
imapCredential?.id
) : {});
setSmtpTestResult(null);
setImapTestResult(null);
setFolderResult(null);
setProfileError("");
}
function selectServer(protocol: "smtp" | "imap", serverId: string) {
if (!selectedProfile || locked) return;
const availableServers = protocol === "smtp" ? smtpServers : imapServers;
const selected = availableServers.find((item) => item.id === serverId) ?? null;
const credential = selected?.credentials.find((item) => item.is_active && item.is_default)
?? selected?.credentials.find((item) => item.is_active)
?? null;
patch(["server"], mailReference(
selectedProfile.id,
protocol === "smtp" ? selected?.id : selectedSmtpServer?.id,
protocol === "smtp" ? credential?.id : selectedSmtpCredential?.id,
protocol === "imap" ? selected?.id : selectedImapServer?.id,
protocol === "imap" ? credential?.id : selectedImapCredential?.id
));
if (protocol === "smtp") setSmtpTestResult(null);
else {
setImapTestResult(null);
setFolderResult(null);
}
}
function selectCredential(protocol: "smtp" | "imap", credentialId: string) {
if (!selectedProfile || locked) return;
patch(["server"], mailReference(
selectedProfile.id,
selectedSmtpServer?.id,
protocol === "smtp" ? credentialId : selectedSmtpCredential?.id,
selectedImapServer?.id,
protocol === "imap" ? credentialId : selectedImapCredential?.id
));
if (protocol === "smtp") setSmtpTestResult(null);
else {
setImapTestResult(null);
setFolderResult(null);
}
}
function openCredentialDialog() {
setCredentialDraft({
name: `${data.campaign?.name || "Campaign"} credential`,
username: "",
password: "",
useSmtp: Boolean(selectedSmtpServer),
useImap: Boolean(selectedImapServer)
});
setCredentialDialogOpen(true);
setLocalError("");
}
async function saveCampaignCredential() {
if (!selectedProfile || credentialSaving) return;
const serverIds = [
credentialDraft.useSmtp ? selectedSmtpServer?.id : null,
credentialDraft.useImap ? selectedImapServer?.id : null
].filter((value): value is string => Boolean(value));
if (
!credentialDraft.name.trim() ||
!credentialDraft.username.trim() ||
!credentialDraft.password ||
serverIds.length === 0
) return;
setCredentialSaving(true);
setLocalError("");
try {
const credential = await createCampaignMailCredential(
settings,
selectedProfile.id,
campaignId,
{
name: credentialDraft.name.trim(),
username: credentialDraft.username.trim(),
password: credentialDraft.password,
server_ids: serverIds
}
);
patch(["server"], mailReference(
selectedProfile.id,
selectedSmtpServer?.id,
credentialDraft.useSmtp ? credential.id : selectedSmtpCredential?.id,
selectedImapServer?.id,
credentialDraft.useImap ? credential.id : selectedImapCredential?.id
));
setCredentialDialogOpen(false);
await refreshMailProfiles();
} catch (err) {
setLocalError(err instanceof Error ? err.message : String(err));
} finally {
setCredentialSaving(false);
}
}
async function runProfileTest(protocol: "smtp" | "imap") {
if (!selectedProfileId || locked) return;
setMailActionState(protocol);
setLocalError("");
try {
if (protocol === "smtp") {
setSmtpTestResult(await testMailProfileSmtp(settings, selectedProfileId));
setSmtpTestResult(await testMailProfileSmtp(
settings,
selectedProfileId,
selectedSmtpServer?.id,
selectedSmtpCredential?.id,
campaignId
));
} else {
setImapTestResult(await testMailProfileImap(settings, selectedProfileId));
setImapTestResult(await testMailProfileImap(
settings,
selectedProfileId,
selectedImapServer?.id,
selectedImapCredential?.id,
campaignId
));
}
} catch (err) {
const result = { ok: false, protocol, message: err instanceof Error ? err.message : String(err), details: {} };
@@ -148,7 +306,13 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
setMailActionState("folders");
setLocalError("");
try {
setFolderResult(await listMailProfileImapFolders(settings, selectedProfileId));
setFolderResult(await listMailProfileImapFolders(
settings,
selectedProfileId,
selectedImapServer?.id,
selectedImapCredential?.id,
campaignId
));
} catch (err) {
setFolderResult({ ok: false, protocol: "imap", message: err instanceof Error ? err.message : String(err), folders: [], details: {} });
} finally {
@@ -204,8 +368,11 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
{!isPolicyView && mailModuleInstalled && <Card
title="i18n:govoplan-campaign.reusable_mail_profile.f9c9aab1"
actions={<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>}>
<p className="muted small-note">i18n:govoplan-campaign.campaign_stores_only_this_stable_profile_referen.de554809</p>
actions={<div className="button-row compact-actions">
<Button onClick={() => void refreshMailProfiles()} disabled={profilesLoading}>{profilesLoading ? "i18n:govoplan-campaign.loading.33ce4174" : "i18n:govoplan-campaign.reload_profiles.0fe100d1"}</Button>
<Button variant="primary" onClick={openCredentialDialog} disabled={locked || profilesLoading || !selectedProfile || !selectedSmtpServer && !selectedImapServer}>Add campaign credential</Button>
</div>}>
<p className="muted small-note">The campaign stores stable references to the envelope, selected servers, and credentials.</p>
<div className="form-grid compact responsive-form-grid">
<FormField label="i18n:govoplan-campaign.profile.ff4fc027">
<select value={selectedProfileId} disabled={locked || profilesLoading} onChange={(event) => selectMailProfile(event.target.value)}>
@@ -213,16 +380,44 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
{mailProfiles.map((profile) => <option key={profile.id} value={profile.id}>{profile.name} ({profileScopeLabel(profile)})</option>)}
</select>
</FormField>
<FormField label="SMTP server">
<select value={selectedSmtpServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("smtp", event.target.value)}>
<option value="">No SMTP server</option>
{smtpServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
</select>
</FormField>
<FormField label="SMTP credential">
<select value={selectedSmtpCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedSmtpServer} onChange={(event) => selectCredential("smtp", event.target.value)}>
<option value="">No credential</option>
{selectedSmtpServer?.credentials.filter((item) => item.is_active).map((item) =>
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
)}
</select>
</FormField>
<FormField label="IMAP server">
<select value={selectedImapServer?.id ?? ""} disabled={locked || profilesLoading || !selectedProfile} onChange={(event) => selectServer("imap", event.target.value)}>
<option value="">No IMAP server</option>
{imapServers.map((item) => <option key={item.id} value={item.id}>{item.name} ({serverEndpointLabel(item.config)})</option>)}
</select>
</FormField>
<FormField label="IMAP credential">
<select value={selectedImapCredential?.id ?? ""} disabled={locked || profilesLoading || !selectedImapServer} onChange={(event) => selectCredential("imap", event.target.value)}>
<option value="">No credential</option>
{selectedImapServer?.credentials.filter((item) => item.is_active).map((item) =>
<option key={item.id} value={item.id}>{credentialLabel(item)}</option>
)}
</select>
</FormField>
</div>
{selectedProfileUnavailable && <DismissibleAlert tone="warning" dismissible={false}>i18n:govoplan-campaign.the_referenced_mail_profile_is_inactive_unavaila.abeebe26</DismissibleAlert>}
{selectedProfile && <div className="metric-grid inside">
<MetricCard label="i18n:govoplan-campaign.profile.ff4fc027" value={selectedProfile.name} />
<MetricCard label="i18n:govoplan-campaign.scope.4651a34e" value={profileScopeLabel(selectedProfile)} />
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value="i18n:govoplan-campaign.configured.668c5fff" tone="good" />
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedProfile.imap ? "i18n:govoplan-campaign.configured.668c5fff" : "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedProfile.imap ? "good" : "neutral"} />
<MetricCard label="i18n:govoplan-campaign.smtp.efff9cca" value={selectedSmtpServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedSmtpServer ? "good" : "neutral"} />
<MetricCard label="i18n:govoplan-campaign.imap.271f9ef2" value={selectedImapServer?.name || "i18n:govoplan-campaign.not_configured.811931bb"} tone={selectedImapServer ? "good" : "neutral"} />
</div>}
<div className="button-row compact-actions">
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedProfile || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
<Button onClick={() => void runProfileTest("smtp")} disabled={!selectedSmtpServer || locked || Boolean(mailActionState)}>{mailActionState === "smtp" ? "i18n:govoplan-campaign.testing_smtp.8e9f8247" : "i18n:govoplan-campaign.test_profile_smtp.884a0e66"}</Button>
<Button onClick={() => void runProfileTest("imap")} disabled={!selectedProfileHasImap || locked || Boolean(mailActionState)}>{mailActionState === "imap" ? "i18n:govoplan-campaign.testing_imap.13d255cf" : "i18n:govoplan-campaign.test_profile_imap.e1cec0e0"}</Button>
</div>
{smtpTestResult && <DismissibleAlert tone={smtpTestResult.ok ? "success" : "danger"} resetKey={`${smtpTestResult.protocol}:${smtpTestResult.message}`} floating>{smtpTestResult.message}</DismissibleAlert>}
@@ -230,6 +425,57 @@ export default function MailSettingsPage({ settings, campaignId, view = "setting
{profileError && <DismissibleAlert tone="warning" resetKey={profileError} floating>{profileError}</DismissibleAlert>}
</Card>}
<Dialog
open={credentialDialogOpen}
title="Add campaign credential"
onClose={() => setCredentialDialogOpen(false)}
closeDisabled={credentialSaving}
className="admin-dialog admin-dialog-wide adaptive-config-dialog"
footerClassName="button-row compact-actions"
footer={
<>
<Button onClick={() => setCredentialDialogOpen(false)} disabled={credentialSaving}>Cancel</Button>
<Button
variant="primary"
onClick={() => void saveCampaignCredential()}
disabled={
credentialSaving ||
!credentialDraft.name.trim() ||
!credentialDraft.username.trim() ||
!credentialDraft.password ||
!credentialDraft.useSmtp && !credentialDraft.useImap
}
>
{credentialSaving ? "Saving" : "Save credential"}
</Button>
</>
}
>
<div className="adaptive-config-form">
<section className="adaptive-config-section">
<header>
<h3>Campaign login</h3>
<p>This credential belongs to this campaign and can use only the selected mail servers.</p>
</header>
<div className="form-grid two">
<FormField label="Name">
<input value={credentialDraft.name} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, name: event.target.value })} autoFocus />
</FormField>
<FormField label="Username">
<input value={credentialDraft.username} disabled={credentialSaving} onChange={(event) => setCredentialDraft({ ...credentialDraft, username: event.target.value })} />
</FormField>
<FormField label="Password">
<PasswordField value={credentialDraft.password} onValueChange={(password) => setCredentialDraft({ ...credentialDraft, password })} disabled={credentialSaving} autoComplete="new-password" />
</FormField>
</div>
<div className="form-grid two">
{selectedSmtpServer && <ToggleSwitch checked={credentialDraft.useSmtp} disabled={credentialSaving} onChange={(useSmtp) => setCredentialDraft({ ...credentialDraft, useSmtp })} label={`Use for SMTP: ${selectedSmtpServer.name}`} />}
{selectedImapServer && <ToggleSwitch checked={credentialDraft.useImap} disabled={credentialSaving} onChange={(useImap) => setCredentialDraft({ ...credentialDraft, useImap })} label={`Use for IMAP: ${selectedImapServer.name}`} />}
</div>
</section>
</div>
</Dialog>
{!isPolicyView && <Card title="i18n:govoplan-campaign.imap_append.8c0d9e96" collapsible>
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
@@ -266,3 +512,28 @@ function profileScopeLabel(profile: MailServerProfile): string {
if (profile.scope_type === "group") return "i18n:govoplan-campaign.group.171a0606";
return "i18n:govoplan-campaign.campaign_scoped_mail_profile.9cbf3505";
}
function mailReference(
profileId: string,
smtpServerId?: string | null,
smtpCredentialId?: string | null,
imapServerId?: string | null,
imapCredentialId?: string | null
): Record<string, string> {
const value: Record<string, string> = { mail_profile_id: profileId };
if (smtpServerId) value.smtp_server_id = smtpServerId;
if (smtpServerId && smtpCredentialId) value.smtp_credential_id = smtpCredentialId;
if (imapServerId) value.imap_server_id = imapServerId;
if (imapServerId && imapCredentialId) value.imap_credential_id = imapCredentialId;
return value;
}
function serverEndpointLabel(config: MailServerEndpoint["config"]): string {
const host = typeof config.host === "string" && config.host ? config.host : "No host";
return config.port ? `${host}:${config.port}` : host;
}
function credentialLabel(credential: MailCredentialEnvelope): string {
const username = credential.public_data?.username;
return username ? `${credential.name} (${String(username)})` : credential.name;
}