initial commit after split
This commit is contained in:
256
webui/src/features/campaigns/utils/templatePlaceholders.ts
Normal file
256
webui/src/features/campaigns/utils/templatePlaceholders.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { asArray, asRecord } from "./campaignView";
|
||||
import { getBool } from "./draftEditor";
|
||||
import { addressesFromValue, type MailboxAddress } from "../../../utils/emailAddresses";
|
||||
|
||||
export type TemplateNamespace = "global" | "local";
|
||||
|
||||
export const RECIPIENT_ADDRESS_FIELD_IDS = ["from", "to", "reply_to", "cc", "bcc"] as const;
|
||||
|
||||
export function recipientAddressTemplateFieldOptions(): Array<{ name: string; label: string }> {
|
||||
return RECIPIENT_ADDRESS_FIELD_IDS.flatMap((name) => [
|
||||
{ name, label: name },
|
||||
{ name: `all_${name}`, label: `all_${name}` }
|
||||
]);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export type TemplatePlaceholder = {
|
||||
raw: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
validNamespace: boolean;
|
||||
display: string;
|
||||
};
|
||||
|
||||
export type UndefinedPlaceholder = TemplatePlaceholder & {
|
||||
reason: "missing-field" | "invalid-namespace";
|
||||
};
|
||||
|
||||
export function extractTemplatePlaceholders(text: string): TemplatePlaceholder[] {
|
||||
const placeholders = new Map<string, TemplatePlaceholder>();
|
||||
const patterns = [/\$\{\s*([^}]+?)\s*\}/g, /\{\{\s*([^}]+?)\s*\}\}/g];
|
||||
for (const pattern of patterns) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(text))) {
|
||||
const raw = match[1].trim();
|
||||
if (!raw || placeholders.has(raw)) continue;
|
||||
placeholders.set(raw, parseTemplatePlaceholder(raw));
|
||||
}
|
||||
}
|
||||
return [...placeholders.values()].sort((a, b) => a.display.localeCompare(b.display));
|
||||
}
|
||||
|
||||
export function parseTemplatePlaceholder(raw: string): TemplatePlaceholder {
|
||||
const cleaned = normalizeTemplatePlaceholderKey(raw);
|
||||
const separator = cleaned.indexOf(":");
|
||||
const namespace = separator > -1 ? cleaned.slice(0, separator).trim() : "";
|
||||
const name = separator > -1 ? cleaned.slice(separator + 1).trim() : cleaned.trim();
|
||||
const validNamespace = namespace === "global" || namespace === "local";
|
||||
return {
|
||||
raw,
|
||||
namespace,
|
||||
name,
|
||||
validNamespace,
|
||||
display: validNamespace ? `${namespace}:${name}` : raw
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeTemplatePlaceholderKey(raw: string): string {
|
||||
return raw.trim()
|
||||
.replace(/^fields\./, "local:")
|
||||
.replace(/^local\./, "local:")
|
||||
.replace(/^global\./, "global:")
|
||||
.replace(/^local::/, "local:")
|
||||
.replace(/^global::/, "global:");
|
||||
}
|
||||
|
||||
export function uniquePlaceholders<T extends TemplatePlaceholder>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
const result: T[] = [];
|
||||
for (const item of items) {
|
||||
const key = item.raw;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildUndefinedPlaceholders(
|
||||
placeholders: TemplatePlaceholder[],
|
||||
availableNames: Set<string>,
|
||||
availableByNamespace?: Partial<Record<TemplateNamespace, Set<string>>>
|
||||
): UndefinedPlaceholder[] {
|
||||
return uniquePlaceholders(placeholders
|
||||
.filter((field) => {
|
||||
if (!field.validNamespace) return true;
|
||||
const namesForNamespace = availableByNamespace?.[field.namespace as TemplateNamespace];
|
||||
if ((namesForNamespace ?? availableNames).has(field.name)) return false;
|
||||
return !(field.namespace === "local" && isRecipientAddressPlaceholderName(field.name));
|
||||
})
|
||||
.map((field): UndefinedPlaceholder => ({
|
||||
...field,
|
||||
reason: field.validNamespace ? "missing-field" : "invalid-namespace"
|
||||
})));
|
||||
}
|
||||
|
||||
export function buildTemplatePreviewContext(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, string> {
|
||||
const context: Record<string, string> = {};
|
||||
const globalValues = asRecord(draft?.global_values);
|
||||
const entryFields = asRecord(entry.fields);
|
||||
const overridePolicy = fieldOverridePolicy(draft);
|
||||
|
||||
for (const field of asArray(draft?.fields).map(asRecord)) {
|
||||
const name = String(field.name || field.id || "").trim();
|
||||
if (!name) continue;
|
||||
addPreviewContextValue(context, name, "global", "");
|
||||
addPreviewContextValue(context, name, "local", "");
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(globalValues)) {
|
||||
addPreviewContextValue(context, key, "global", value);
|
||||
addPreviewContextValue(context, key, "local", value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(entryFields)) {
|
||||
if (canOverrideField(overridePolicy, key) && hasPreviewOverrideValue(value)) {
|
||||
addPreviewContextValue(context, key, "local", value);
|
||||
}
|
||||
}
|
||||
if (entry.name) addPreviewContextValue(context, "name", "local", entry.name);
|
||||
if (entry.email) addPreviewContextValue(context, "email", "local", entry.email);
|
||||
addRecipientAddressPreviewValues(context, effectivePreviewAddresses(draft, entry));
|
||||
return context;
|
||||
}
|
||||
|
||||
function effectivePreviewAddresses(draft: Record<string, unknown> | null, entry: Record<string, unknown>): Record<string, MailboxAddress[]> {
|
||||
const recipients = asRecord(draft?.recipients);
|
||||
const defaults = asRecord(asRecord(draft?.entries).defaults);
|
||||
const result: Record<string, MailboxAddress[]> = {};
|
||||
for (const field of RECIPIENT_ADDRESS_FIELD_IDS) {
|
||||
const globalAddresses = addressesFromValue(recipients[field]);
|
||||
const localAddresses = addressesFromValue(entry[field]);
|
||||
const allowIndividual = getBool(recipients, `allow_individual_${field}`, field === "to");
|
||||
const defaultMerge = field !== "from";
|
||||
const merge = mergeFlag(entry, defaults, field, defaultMerge);
|
||||
if (!allowIndividual) {
|
||||
result[field] = dedupeAddressOrder(globalAddresses);
|
||||
} else if (localAddresses.length === 0) {
|
||||
result[field] = dedupeAddressOrder(globalAddresses);
|
||||
} else {
|
||||
result[field] = dedupeAddressOrder(merge ? [...globalAddresses, ...localAddresses] : localAddresses);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function dedupeAddressOrder(addresses: MailboxAddress[]): MailboxAddress[] {
|
||||
const seen = new Set<string>();
|
||||
return addresses.filter((address) => {
|
||||
const key = address.email.trim().toLowerCase();
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function mergeFlag(entry: Record<string, unknown>, defaults: Record<string, unknown>, field: string, fallback: boolean): boolean {
|
||||
const mergeKey = `merge_${field}`;
|
||||
const legacyKey = `combine_${field}`;
|
||||
if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean;
|
||||
if (typeof entry[legacyKey] === "boolean") return entry[legacyKey] as boolean;
|
||||
if (typeof defaults[mergeKey] === "boolean") return defaults[mergeKey] as boolean;
|
||||
if (typeof defaults[legacyKey] === "boolean") return defaults[legacyKey] as boolean;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function addRecipientAddressPreviewValues(context: Record<string, string>, addresses: Record<string, MailboxAddress[]>) {
|
||||
for (const field of RECIPIENT_ADDRESS_FIELD_IDS) {
|
||||
const values = addresses[field] ?? [];
|
||||
const formatted = values.map(formatPreviewAddress);
|
||||
const emails = values.map((address) => address.email);
|
||||
const names = values.map((address) => address.name ?? "");
|
||||
addPreviewContextValue(context, field, "local", formatted[0] ?? "");
|
||||
addPreviewContextValue(context, `${field}.email`, "local", emails[0] ?? "");
|
||||
addPreviewContextValue(context, `${field}.name`, "local", names[0] ?? "");
|
||||
addPreviewContextValue(context, `all_${field}`, "local", formatted.join(", "));
|
||||
addPreviewContextValue(context, `all_${field}.email`, "local", emails.join(", "));
|
||||
addPreviewContextValue(context, `all_${field}.name`, "local", names.filter(Boolean).join(", "));
|
||||
values.forEach((address, index) => {
|
||||
const oneBased = index + 1;
|
||||
addPreviewContextValue(context, `${field}[${oneBased}]`, "local", formatted[index]);
|
||||
addPreviewContextValue(context, `${field}[${oneBased}].email`, "local", address.email);
|
||||
addPreviewContextValue(context, `${field}[${oneBased}].name`, "local", address.name ?? "");
|
||||
addPreviewContextValue(context, `${field}.${index}.email`, "local", address.email);
|
||||
addPreviewContextValue(context, `${field}.${index}.name`, "local", address.name ?? "");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatPreviewAddress(address: MailboxAddress): string {
|
||||
return address.name ? `${address.name} <${address.email}>` : address.email;
|
||||
}
|
||||
|
||||
export function renderTemplatePreviewText(text: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
||||
if (!text) return "";
|
||||
return text
|
||||
.replace(/\$\{\s*([^}]+?)\s*\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields))
|
||||
.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, raw: string) => previewValueFor(raw, context, ignoreEmptyFields));
|
||||
}
|
||||
|
||||
export function valueToPreview(value: unknown): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
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"), "");
|
||||
}
|
||||
|
||||
function fieldOverridePolicy(draft: Record<string, unknown> | null): Map<string, boolean> {
|
||||
const policy = new Map<string, boolean>();
|
||||
for (const field of asArray(draft?.fields).map(asRecord)) {
|
||||
const name = String(field.name || field.id || "").trim();
|
||||
if (!name) continue;
|
||||
policy.set(name, getBool(field, "can_override", true));
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
function canOverrideField(policy: Map<string, boolean>, name: string): boolean {
|
||||
if (!policy.has(name)) return true;
|
||||
return policy.get(name) !== false;
|
||||
}
|
||||
|
||||
function addPreviewContextValue(context: Record<string, string>, key: string, namespace: TemplateNamespace, value: unknown) {
|
||||
const text = valueToPreview(value);
|
||||
context[key] = text;
|
||||
context[`${namespace}:${key}`] = text;
|
||||
context[`${namespace}::${key}`] = text;
|
||||
}
|
||||
|
||||
function hasPreviewOverrideValue(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
return true;
|
||||
}
|
||||
|
||||
function previewValueFor(raw: string, context: Record<string, string>, ignoreEmptyFields: boolean): string {
|
||||
const key = normalizeTemplatePlaceholderKey(raw);
|
||||
const value = context[key];
|
||||
if (value !== undefined) return value;
|
||||
return ignoreEmptyFields ? "" : `{{${raw.trim()}}}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user