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(); 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(items: T[]): T[] { const seen = new Set(); 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, availableByNamespace?: Partial>> ): 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 | null, entry: Record): Record { const context: Record = {}; 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 | null, entry: Record): Record { const recipients = asRecord(draft?.recipients); const defaults = asRecord(asRecord(draft?.entries).defaults); const result: Record = {}; 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(); 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, defaults: Record, 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, addresses: Record) { 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, 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 | null): Map { const policy = new Map(); 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, name: string): boolean { if (!policy.has(name)) return true; return policy.get(name) !== false; } function addPreviewContextValue(context: Record, 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, 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, "\\$&"); }