Harden campaign import handling

This commit is contained in:
2026-07-11 18:37:51 +02:00
parent 30b43913e8
commit 3f0b14a726
4 changed files with 95 additions and 24 deletions

View File

@@ -749,9 +749,22 @@ function parseAddress(value: string): ImportedAddress {
}
function splitCell(value: string, separators: string): string[] {
const escaped = separators.split("").map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("");
if (!escaped) return [value.trim()].filter(Boolean);
return value.split(new RegExp(`[${escaped}]+`)).map((item) => item.trim()).filter(Boolean);
const separatorSet = new Set(separators.split(""));
if (separatorSet.size === 0) return [value.trim()].filter(Boolean);
const items: string[] = [];
let current = "";
for (const char of value) {
if (separatorSet.has(char)) {
const trimmed = current.trim();
if (trimmed) items.push(trimmed);
current = "";
continue;
}
current += char;
}
const trimmed = current.trim();
if (trimmed) items.push(trimmed);
return items;
}
function parseOptionalBoolean(value: string, fallback: boolean): boolean {

View File

@@ -21,6 +21,10 @@ export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export function isSafeObjectPathSegment(segment: string): boolean {
return segment !== "__proto__" && segment !== "prototype" && segment !== "constructor";
}
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
return version?.raw_json ?? version?.campaign_json ?? {};
}
@@ -179,10 +183,11 @@ export function getString(record: Record<string, unknown>, key: string, fallback
}
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
if (!path.every(isSafeObjectPathSegment)) return fallback;
let current: unknown = record;
for (const part of path) {
if (!isRecord(current)) return fallback;
current = current[part];
current = Object.getOwnPropertyDescriptor(current, part)?.value;
}
if (typeof current === "string" && current.trim()) return current;
if (typeof current === "number" || typeof current === "boolean") return String(current);

View File

@@ -1,5 +1,5 @@
import type { CampaignVersionDetail } from "../../../api/campaigns";
import { asRecord, getCampaignJson, isRecord } from "./campaignView";
import { asRecord, getCampaignJson, isRecord, isSafeObjectPathSegment } from "./campaignView";
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
@@ -46,19 +46,32 @@ export function updateNested(
path: string[],
value: unknown
): Record<string, unknown> {
if (!path.length || !path.every(isSafeObjectPathSegment)) return cloneJson(draft);
const next = cloneJson(draft);
let current: Record<string, unknown> = next;
path.forEach((segment, index) => {
for (const [index, segment] of path.entries()) {
if (index === path.length - 1) {
current[segment] = value;
return;
Object.defineProperty(current, segment, {
configurable: true,
enumerable: true,
value,
writable: true
});
break;
}
const existing = current[segment];
const existing = Object.getOwnPropertyDescriptor(current, segment)?.value;
if (!isRecord(existing)) {
current[segment] = {};
Object.defineProperty(current, segment, {
configurable: true,
enumerable: true,
value: {},
writable: true
});
}
current = current[segment] as Record<string, unknown>;
});
const child = Object.getOwnPropertyDescriptor(current, segment)?.value;
if (!isRecord(child)) return next;
current = child;
}
return next;
}

View File

@@ -13,10 +13,48 @@ export function recipientAddressTemplateFieldOptions(): Array<{ name: string; la
}
export function isRecipientAddressPlaceholderName(name: string): boolean {
const field = "(?:from|to|reply_to|cc|bcc)";
return new RegExp(`^(?:all_)?${field}(?:\\.(?:email|name))?$`).test(name)
|| new RegExp(`^${field}\\[[1-9]\\d*\\](?:\\.(?:email|name))?$`).test(name)
|| new RegExp(`^${field}\\.\\d+\\.(?:email|name|type)$`).test(name);
const normalized = name.trim();
const allPrefix = "all_";
const fieldName = recipientAddressFieldFromPlaceholder(normalized);
if (!fieldName) return false;
if (normalized === fieldName || normalized === `${allPrefix}${fieldName}`) return true;
if (["email", "name"].some((suffix) => normalized === `${fieldName}.${suffix}` || normalized === `${allPrefix}${fieldName}.${suffix}`)) return true;
if (isIndexedRecipientAddressPlaceholder(normalized, fieldName)) return true;
return isDottedRecipientAddressPlaceholder(normalized, fieldName);
}
function recipientAddressFieldFromPlaceholder(value: string): string | null {
const candidates = [...RECIPIENT_ADDRESS_FIELD_IDS].sort((left, right) => right.length - left.length);
for (const field of candidates) {
if (value === field || value.startsWith(`${field}.`) || value.startsWith(`${field}[`)) return field;
const allField = `all_${field}`;
if (value === allField || value.startsWith(`${allField}.`)) return field;
}
return null;
}
function isPositiveInteger(value: string): boolean {
return value.length > 0 && value[0] !== "0" && [...value].every((char) => char >= "0" && char <= "9");
}
function isNonNegativeInteger(value: string): boolean {
return value.length > 0 && [...value].every((char) => char >= "0" && char <= "9");
}
function isIndexedRecipientAddressPlaceholder(value: string, fieldName: string): boolean {
if (!value.startsWith(`${fieldName}[`)) return false;
const closeIndex = value.indexOf("]", fieldName.length + 1);
if (closeIndex < 0) return false;
const index = value.slice(fieldName.length + 1, closeIndex);
const suffix = value.slice(closeIndex + 1);
return isPositiveInteger(index) && (suffix === "" || suffix === ".email" || suffix === ".name");
}
function isDottedRecipientAddressPlaceholder(value: string, fieldName: string): boolean {
const prefix = `${fieldName}.`;
if (!value.startsWith(prefix)) return false;
const [index, suffix, extra] = value.slice(prefix.length).split(".");
return extra === undefined && isNonNegativeInteger(index) && ["email", "name", "type"].includes(suffix);
}
export type TemplatePlaceholder = {
@@ -211,14 +249,20 @@ export function valueToPreview(value: unknown): string {
export function removePlaceholderFromText(text: string, raw: string): string {
if (!text) return text;
const escaped = escapeRegExp(raw.trim());
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), "");
return replaceMatchingPlaceholders(text, raw, "");
}
export function replacePlaceholderInText(text: string, raw: string, replacement: string): string {
if (!text) return text;
const escaped = escapeRegExp(raw.trim());
return text.replace(new RegExp(`\\{\\{\\s*${escaped}\\s*\\}\\}|\\$\\{\\s*${escaped}\\s*\\}`, "g"), replacement);
return replaceMatchingPlaceholders(text, raw, replacement);
}
function replaceMatchingPlaceholders(text: string, raw: string, replacement: string): string {
const target = raw.trim();
if (!target) return text;
return text.replace(/\{\{\s*([^}]+?)\s*\}\}|\$\{\s*([^}]+?)\s*\}/g, (match, braceRaw: string | undefined, dollarRaw: string | undefined) =>
(braceRaw ?? dollarRaw ?? "").trim() === target ? replacement : match
);
}
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
@@ -255,7 +299,3 @@ function previewValueFor(raw: string, context: Record<string, string>, ignoreEmp
if (value !== undefined) return value;
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}