257 lines
10 KiB
TypeScript
257 lines
10 KiB
TypeScript
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, "\\$&");
|
|
}
|