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
@@ -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 {