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

View File

@@ -1,11 +1,12 @@
import type {
ApiSettings,
MailConnectionTestResponse,
MailCredentialEnvelope,
MailImapFolderListResponse,
MailServerProfile,
MockMailboxMessageResponse
} from "@govoplan/core-webui";
import { apiFetch, apiGetList, apiPost } from "./client";
import { apiFetch, apiGetList, apiPath, apiPost } from "./client";
const profileActionEndpoints = {
smtp: "test-smtp",
@@ -16,11 +17,21 @@ const profileActionEndpoints = {
function runProfileAction<TResponse>(
settings: ApiSettings,
profileId: string,
action: keyof typeof profileActionEndpoints
action: keyof typeof profileActionEndpoints,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<TResponse> {
return apiPost<TResponse>(
settings,
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`
apiPath(
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/${profileActionEndpoints[action]}`,
{
server_id: serverId,
credential_id: credentialId,
campaign_id: campaignId
}
)
);
}
@@ -31,16 +42,58 @@ export async function listMailServerProfiles(settings: ApiSettings, includeInact
});
}
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp");
export function createCampaignMailCredential(
settings: ApiSettings,
profileId: string,
campaignId: string,
payload: {
name: string;
username: string;
password: string;
server_ids: string[];
}
): Promise<MailCredentialEnvelope> {
return apiFetch<MailCredentialEnvelope>(
settings,
apiPath(
`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/campaign-credentials`,
{ campaign_id: campaignId }
),
{
method: "POST",
body: JSON.stringify(payload)
}
);
}
export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap");
export async function testMailProfileSmtp(
settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "smtp", serverId, credentialId, campaignId);
}
export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise<MailImapFolderListResponse> {
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders");
export async function testMailProfileImap(
settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return runProfileAction<MailConnectionTestResponse>(settings, profileId, "imap", serverId, credentialId, campaignId);
}
export async function listMailProfileImapFolders(
settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailImapFolderListResponse> {
return runProfileAction<MailImapFolderListResponse>(settings, profileId, "folders", serverId, credentialId, campaignId);
}
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {

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;
}

View File

@@ -232,27 +232,23 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
replaceInlineEntries(nextEntries);
}
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
updateEntry(index, (entry) => entryWithAddressList(entry, key, addresses));
}
function updateEntryAddressGroups(index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) {
function saveEntryAddresses(
index: number,
values: HeaderAddressValues,
merges: EntryAddressMergeValues
) {
updateEntry(index, (entry) => {
let nextEntry = entry;
for (const group of groups) {
const currentAddresses = getEntryAddresses(nextEntry, group.key);
nextEntry = entryWithAddressList(nextEntry, group.key, dedupeAddresses([...currentAddresses, ...group.addresses]));
for (const column of recipientAddressOverlayColumns) {
if (!(column.key in values)) continue;
nextEntry = entryWithAddressList(nextEntry, column.key, values[column.key] ?? []);
if (!column.mergeKey || !(column.mergeKey in merges)) continue;
nextEntry = { ...nextEntry, [column.mergeKey]: Boolean(merges[column.mergeKey]) };
delete nextEntry[column.mergeKey.replace("merge_", "combine_")];
}
return nextEntry;
});
}
function updateEntryMerge(index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) {
updateEntry(index, (entry) => {
const next = { ...entry, [mergeKey]: checked };
delete next[mergeKey.replace("merge_", "combine_")];
return next;
});
setRecipientAddressEditorIndex(null);
}
function updateEntryField(index: number, field: string, value: unknown) {
@@ -305,8 +301,13 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
setAddressSourceImportOpen(false);
}
function updateHeaderAddressList(key: AddressFieldKey, addresses: MailboxAddress[]) {
patch(["recipients", key], key === "from" ? addresses.slice(0, 1) : addresses);
function saveHeaderAddresses(values: HeaderAddressValues) {
const nextRecipients = { ...recipientsSection };
for (const [key, addresses] of Object.entries(values) as Array<[AddressFieldKey, MailboxAddress[]]>) {
nextRecipients[key] = key === "from" ? addresses.slice(0, 1) : addresses;
}
patch(["recipients"], nextRecipients);
setHeaderAddressEditor(null);
}
async function copyHeaderAddresses(columns: EntryAddressColumn[]) {
@@ -508,9 +509,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
locked={locked}
recipientsSection={recipientsSection}
entryDefaults={entryDefaults}
updateEntryAddressList={updateEntryAddressList}
updateEntryAddressGroups={updateEntryAddressGroups}
updateEntryMerge={updateEntryMerge}
onSave={(values, merges) => saveEntryAddresses(recipientAddressEditorIndex, values, merges)}
onClose={() => setRecipientAddressEditorIndex(null)} />
}
@@ -520,7 +519,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
columns={headerAddressEditor.columns}
values={headerAddressValues(headerAddressEditor.columns, recipientsSection)}
locked={locked}
onAddressesChange={updateHeaderAddressList}
onSave={saveHeaderAddresses}
onClose={() => setHeaderAddressEditor(null)} />
}
@@ -534,13 +533,12 @@ type RecipientAddressEditorDialogProps = {
locked: boolean;
recipientsSection: Record<string, unknown>;
entryDefaults: Record<string, unknown>;
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
updateEntryAddressGroups: (index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) => void;
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
onSave: (values: HeaderAddressValues, merges: EntryAddressMergeValues) => void;
onClose: () => void;
};
type HeaderAddressValues = Partial<Record<AddressFieldKey, MailboxAddress[]>>;
type EntryAddressMergeValues = Partial<Record<NonNullable<EntryAddressColumn["mergeKey"]>, boolean>>;
type AddressHeaderControlProps = {
columns: EntryAddressColumn[];
@@ -596,26 +594,33 @@ type HeaderAddressEditorDialogProps = {
columns: EntryAddressColumn[];
values: HeaderAddressValues;
locked: boolean;
onAddressesChange: (key: AddressFieldKey, addresses: MailboxAddress[]) => void;
onSave: (values: HeaderAddressValues) => void;
onClose: () => void;
};
function HeaderAddressEditorDialog({ title, columns, values, locked, onAddressesChange, onClose }: HeaderAddressEditorDialogProps) {
function HeaderAddressEditorDialog({ title, columns, values, locked, onSave, onClose }: HeaderAddressEditorDialogProps) {
const { translateText } = usePlatformLanguage();
const columnKeys = new Set(columns.map((column) => column.key));
const [draftValues, setDraftValues] = useState<HeaderAddressValues>(() => cloneAddressValues(columns, values));
const [validationError, setValidationError] = useState("");
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key));
if (groups.length === 0) return false;
const nextValues: HeaderAddressValues = { ...values };
for (const group of groups) {
nextValues[group.key] = dedupeAddresses([...(nextValues[group.key] ?? []), ...group.addresses]);
}
for (const group of groups) {
onAddressesChange(group.key, nextValues[group.key] ?? []);
}
setDraftValues((current) => mergeAddressGroups(current, groups));
setValidationError("");
return true;
}
function save() {
const prepared = prepareAddressValues(columns, draftValues, translateText);
if (prepared.error) {
setValidationError(prepared.error);
return;
}
onSave(prepared.values);
}
return (
<Dialog
open
@@ -623,17 +628,24 @@ function HeaderAddressEditorDialog({ title, columns, values, locked, onAddresses
className="recipient-address-editor-modal"
bodyClassName="recipient-address-editor-body"
onClose={onClose}
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
footer={<>
<Button onClick={onClose}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={locked} onClick={save}>i18n:govoplan-campaign.save.efc007a3</Button>
</>}>
<div className="recipient-address-editor">
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
{columns.map((column) =>
<RecipientAddressCategoryEditor
key={column.key}
column={column}
addresses={values[column.key] ?? []}
addresses={draftValues[column.key] ?? []}
merge={false}
locked={locked}
onAddressesChange={(addresses) => onAddressesChange(column.key, addresses)}
onAddressesChange={(addresses) => {
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
setValidationError("");
}}
onPasteAddresses={applyPastedAddresses} />
)}
@@ -647,21 +659,41 @@ function RecipientAddressEditorDialog({
locked,
recipientsSection,
entryDefaults,
updateEntryAddressList,
updateEntryAddressGroups,
updateEntryMerge,
onSave,
onClose
}: RecipientAddressEditorDialogProps) {
const { translateText } = usePlatformLanguage();
const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key));
const availableKeys = new Set(availableColumns.map((column) => column.key));
const [draftValues, setDraftValues] = useState<HeaderAddressValues>(() =>
Object.fromEntries(availableColumns.map((column) => [column.key, getEntryAddresses(entry, column.key).map(cloneMailboxAddress)]))
);
const [draftMerges, setDraftMerges] = useState<EntryAddressMergeValues>(() =>
Object.fromEntries(
availableColumns
.filter((column) => column.mergeKey)
.map((column) => [column.mergeKey!, getEntryMerge(entry, entryDefaults, column.mergeKey!)])
)
);
const [validationError, setValidationError] = useState("");
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key));
if (groups.length === 0) return false;
updateEntryAddressGroups(index, groups);
setDraftValues((current) => mergeAddressGroups(current, groups));
setValidationError("");
return true;
}
function save() {
const prepared = prepareAddressValues(availableColumns, draftValues, translateText);
if (prepared.error) {
setValidationError(prepared.error);
return;
}
onSave(prepared.values, draftMerges);
}
return (
<Dialog
open
@@ -669,9 +701,13 @@ function RecipientAddressEditorDialog({
className="recipient-address-editor-modal"
bodyClassName="recipient-address-editor-body"
onClose={onClose}
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
footer={<>
<Button onClick={onClose}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={locked} onClick={save}>i18n:govoplan-campaign.save.efc007a3</Button>
</>}>
<div className="recipient-address-editor">
{validationError && <DismissibleAlert tone="danger" dismissible={false}>{validationError}</DismissibleAlert>}
{availableColumns.length === 0 &&
<p className="muted">No individual recipient address fields are enabled.</p>
}
@@ -679,11 +715,16 @@ function RecipientAddressEditorDialog({
<RecipientAddressCategoryEditor
key={column.key}
column={column}
addresses={getEntryAddresses(entry, column.key)}
merge={column.mergeKey ? getEntryMerge(entry, entryDefaults, column.mergeKey) : false}
addresses={draftValues[column.key] ?? []}
merge={column.mergeKey ? Boolean(draftMerges[column.mergeKey]) : false}
locked={locked}
onAddressesChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
onMergeChange={column.mergeKey ? (merge) => updateEntryMerge(index, column.mergeKey!, merge) : undefined}
onAddressesChange={(addresses) => {
setDraftValues((current) => ({ ...current, [column.key]: addresses }));
setValidationError("");
}}
onMergeChange={column.mergeKey ? (merge) => {
setDraftMerges((current) => ({ ...current, [column.mergeKey!]: merge }));
} : undefined}
onPasteAddresses={applyPastedAddresses} />
)}
@@ -716,7 +757,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
function commitAddresses(nextAddresses: MailboxAddress[]) {
setDraftAddresses(nextAddresses);
onAddressesChange(nextAddresses.filter((address) => String(address.email ?? "").trim() || String(address.name ?? "").trim()));
onAddressesChange(nextAddresses);
}
function patchAddress(addressIndex: number, patch: Partial<MailboxAddress>) {
@@ -731,7 +772,7 @@ function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAd
{ name: "", email: "" },
...draftAddresses.slice(insertIndex)];
setDraftAddresses(nextAddresses);
commitAddresses(nextAddresses);
}
function removeAddress(addressIndex: number) {
@@ -2147,9 +2188,59 @@ function getAddressColumn(key: AddressFieldKey): EntryAddressColumn {
return column;
}
function cloneMailboxAddress(address: MailboxAddress): MailboxAddress {
return { name: address.name ?? "", email: address.email ?? "" };
}
function cloneAddressValues(columns: EntryAddressColumn[], values: HeaderAddressValues): HeaderAddressValues {
return Object.fromEntries(
columns.map((column) => [column.key, (values[column.key] ?? []).map(cloneMailboxAddress)])
) as HeaderAddressValues;
}
function mergeAddressGroups(
values: HeaderAddressValues,
groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>
): HeaderAddressValues {
const nextValues = cloneAddressValues(recipientAddressOverlayColumns, values);
for (const group of groups) {
nextValues[group.key] = dedupeAddresses([
...(nextValues[group.key] ?? []),
...group.addresses.map(cloneMailboxAddress)
]);
}
return nextValues;
}
function prepareAddressValues(
columns: EntryAddressColumn[],
values: HeaderAddressValues,
translateText: (value: string) => string
): {values: HeaderAddressValues;error: string;} {
const prepared: HeaderAddressValues = {};
for (const column of columns) {
const addresses: MailboxAddress[] = [];
for (const address of values[column.key] ?? []) {
const name = String(address.name ?? "").trim();
const email = String(address.email ?? "").trim();
if (!name && !email) continue;
if (!email) {
return {
values: prepared,
error: `${translateText(column.label)}: enter an email address or remove the incomplete row.`
};
}
addresses.push({ name, email });
}
prepared[column.key] = column.allowMultiple ? dedupeAddresses(addresses) : addresses.slice(0, 1);
}
return { values: prepared, error: "" };
}
function normalizeEditorAddressRows(column: EntryAddressColumn, addresses: MailboxAddress[]): MailboxAddress[] {
if (column.allowMultiple) return addresses;
return addresses.length > 0 ? addresses.slice(0, 1) : [{ name: "", email: "" }];
const rows = addresses.map(cloneMailboxAddress);
if (column.allowMultiple) return rows;
return rows.length > 0 ? rows.slice(0, 1) : [{ name: "", email: "" }];
}
function formatMailboxAddress(address: MailboxAddress): string {

View File

@@ -1,13 +1,26 @@
export function campaignMailProfileReferenceOnly(value: Record<string, unknown>): Record<string, unknown> {
const server = isRecord(value.server) ? value.server : {};
const rawProfileId = server.mail_profile_id;
const profileId = typeof rawProfileId === "string" ? rawProfileId.trim() : "";
const profileId = textId(server.mail_profile_id);
const smtpServerId = textId(server.smtp_server_id);
const smtpCredentialId = textId(server.smtp_credential_id);
const imapServerId = textId(server.imap_server_id);
const imapCredentialId = textId(server.imap_credential_id);
const reference: Record<string, string> = {};
if (profileId) reference.mail_profile_id = profileId;
if (profileId && smtpServerId) reference.smtp_server_id = smtpServerId;
if (profileId && smtpServerId && smtpCredentialId) reference.smtp_credential_id = smtpCredentialId;
if (profileId && imapServerId) reference.imap_server_id = imapServerId;
if (profileId && imapServerId && imapCredentialId) reference.imap_credential_id = imapCredentialId;
return {
...value,
server: profileId ? { mail_profile_id: profileId } : {}
server: reference
};
}
function textId(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}

View File

@@ -1003,12 +1003,13 @@
.recipient-address-empty-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(150px, .7fr) minmax(220px, 1fr) auto;
gap: 8px;
align-items: center;
}
.recipient-address-empty {
grid-column: 1 / 3;
margin: 0;
}
@@ -1082,6 +1083,10 @@
grid-template-columns: 1fr;
}
.recipient-address-empty {
grid-column: auto;
}
.recipient-address-category-header {
align-items: flex-start;
flex-direction: column;
@@ -1218,8 +1223,8 @@
/* Shared message preview overlay --------------------------------------- */
.message-preview-modal {
width: min(920px, 100%);
height: min(780px, calc(100dvh - 48px));
width: min(1104px, calc(100vw - 48px));
height: min(936px, calc(100dvh - 48px));
max-height: calc(100dvh - 48px);
}
@@ -1237,15 +1242,16 @@
.message-preview-modal .modal-body {
flex: 1 1 auto;
min-height: 0;
display: grid;
align-content: start;
gap: 1rem;
overflow: auto;
scrollbar-gutter: stable;
overflow: hidden;
}
.message-preview-content {
display: contents;
display: flex;
flex-direction: column;
gap: 1rem;
height: 100%;
min-height: 0;
overflow: hidden;
}
.message-preview-modal .modal-footer {
@@ -1257,10 +1263,24 @@
margin-right: auto;
}
.message-preview-modal .message-display-panel {
flex: 1 1 auto;
grid-template-rows: auto minmax(0, 1fr) auto;
min-height: 0;
overflow: hidden;
}
.message-preview-modal .message-display-body-section {
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.message-preview-modal .message-display-body,
.message-preview-modal .message-display-html-frame {
height: clamp(280px, 45vh, 520px);
max-height: clamp(280px, 45vh, 520px);
height: 100%;
min-height: 0;
max-height: none;
}
.message-preview-meta {
@@ -1272,6 +1292,17 @@
margin: 0 0 0.5rem;
}
.message-preview-modal .message-display-attachments {
flex: 0 0 auto;
min-height: 0;
overflow: hidden;
}
.message-preview-modal .message-display-attachments-scroll {
max-height: 116px;
overflow: auto;
}
.attachment-chip-grid {
display: flex;
flex-wrap: wrap;
@@ -1303,6 +1334,9 @@
}
.message-preview-raw {
flex: 0 0 auto;
max-height: 132px;
overflow: auto;
border-top: 1px solid var(--line-subtle);
padding-top: 0.75rem;
}