initial commit after split

This commit is contained in:
2026-06-24 01:43:27 +02:00
parent 2406e70597
commit 7f25a3ccdf
125 changed files with 25551 additions and 0 deletions

View File

@@ -0,0 +1,273 @@
import type { FileSpace } from "../../../api/files";
import { asArray, asRecord, isRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
export type AttachmentRule = Record<string, unknown>;
export type AttachmentZipPasswordScope = "local" | "global";
export type AttachmentZipArchive = {
id: string;
name: string;
standard: boolean;
password_enabled: boolean;
password_field: string;
password_scope: AttachmentZipPasswordScope;
method: "aes" | "zip_standard";
// Read-only compatibility values retained when normalizing older campaigns.
password_mode?: "none" | "direct" | "field" | "template";
password?: string;
password_template?: string;
};
export type AttachmentZipCollection = {
enabled: boolean;
archives: AttachmentZipArchive[];
};
export function createAttachmentZipArchive(name = "attachments.zip", standard = false): AttachmentZipArchive {
return {
id: `zip-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
standard,
password_enabled: false,
password_field: "",
password_scope: "local",
method: "aes"
};
}
export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipCollection {
const zip = asRecord(value);
const rawArchives = asArray(zip.archives).filter(isRecord);
if (rawArchives.length > 0 || Object.prototype.hasOwnProperty.call(zip, "archives")) {
return {
enabled: getBool(zip, "enabled"),
archives: rawArchives.map((archive, index) => {
const legacyMode = getText(archive, "password_mode") as AttachmentZipArchive["password_mode"];
return {
id: getText(archive, "id", `zip-${index + 1}`),
name: getText(archive, "name", getText(archive, "filename_template", "attachments.zip")),
standard: getBool(archive, "standard"),
password_enabled: getBool(archive, "password_enabled", ["direct", "field", "template"].includes(legacyMode || "")),
password_field: getText(archive, "password_field"),
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
...(legacyMode ? { password_mode: legacyMode } : {}),
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
};
})
};
}
// Upgrade the first, single-archive implementation in memory.
const legacyEnabled = getBool(zip, "enabled");
const legacyName = getText(zip, "filename_template");
const legacyMode = getText(zip, "password_mode", getText(zip, "password_template") ? "template" : "none") as AttachmentZipArchive["password_mode"];
const meaningful = legacyEnabled || Boolean(legacyName || getText(zip, "password") || getText(zip, "password_field") || getText(zip, "password_template"));
if (!meaningful) return { enabled: false, archives: [] };
return {
enabled: legacyEnabled,
archives: [{
id: "default",
name: legacyName || "attachments.zip",
standard: true,
password_enabled: ["direct", "field", "template"].includes(legacyMode || ""),
password_field: getText(zip, "password_field"),
password_scope: "local",
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
...(legacyMode ? { password_mode: legacyMode } : {}),
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
}]
};
}
export function attachmentRuleZipSelection(rule: AttachmentRule): string {
const zip = asRecord(rule.zip);
const archiveId = getText(zip, "archive_id");
if (archiveId) return archiveId;
const legacyMode = getText(zip, "mode");
if (legacyMode === "exclude") return "exclude";
return "inherit";
}
export type ManagedAttachmentSource = {
ownerType: "user" | "group";
ownerId: string;
};
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
export function encodeManagedAttachmentSource(space: Pick<FileSpace, "owner_type" | "owner_id">): string {
return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
}
export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentSource | null {
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null;
const [, ownerType, ...ownerIdParts] = value.split(":");
const ownerId = ownerIdParts.join(":").trim();
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
return { ownerType, ownerId };
}
export type AttachmentBasePath = {
id: string;
name: string;
path: string;
source?: string;
allow_individual?: boolean;
unsent_warning?: boolean;
};
export type AttachmentSummary = {
direct: number;
rules: number;
};
export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
label: string;
};
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
{ label: "Campaign attachments", path: "attachments" },
{ label: "Campaign root", path: "." },
{ label: "Shared group files", path: "group/shared" },
{ label: "Tenant templates", path: "tenant/templates" },
{ label: "Personal upload area", path: "user/uploads" }
];
export function createAttachmentBasePath(name = "New attachment source", path = "."): AttachmentBasePath {
return {
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
path,
allow_individual: false,
unsent_warning: false
};
}
export function createAttachmentRule(baseDir = "", label = "", basePathId = ""): AttachmentRule {
return {
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
label,
base_path_id: basePathId || undefined,
base_dir: baseDir,
file_filter: "",
required: true,
include_subdirs: false,
zip: { archive_id: "inherit" }
};
}
export function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>, fallbackAllowIndividual = false): AttachmentBasePath[] {
if (Array.isArray(value) && value.length > 0) {
return value.filter(isRecord).map((basePath, index) => ({
id: getText(basePath, "id", `base-path-${index + 1}`),
name: getText(basePath, "name", `Base path ${index + 1}`),
source: getText(basePath, "source"),
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
allow_individual: getBool(basePath, "allow_individual"),
unsent_warning: getBool(basePath, "unsent_warning")
}));
}
return [{
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
unsent_warning: false
}];
}
export function ensureAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
return paths.length > 0 ? paths : [createAttachmentBasePath("Campaign files", ".")];
}
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
return paths.filter((basePath) => basePath.allow_individual);
}
export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: AttachmentBasePath): boolean {
const basePathId = getText(rule, "base_path_id");
if (basePathId) return basePathId === basePath.id;
return getText(rule, "base_dir") === basePath.path;
}
export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number {
const entries = asRecord(entriesValue);
return asArray(entries.inline)
.map(asRecord)
.flatMap((entry) => normalizeAttachmentRules(entry.attachments))
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath))
.length;
}
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
const entries = asRecord(entriesValue);
const inline = asArray(entries.inline).map(asRecord).map((entry) => ({
...entry,
attachments: normalizeAttachmentRules(entry.attachments).filter((rule) => !attachmentRuleUsesBasePath(rule, basePath))
}));
return { ...entries, inline };
}
export function nextAttachmentLabel(rules: AttachmentRule[]): string {
let highest = 0;
for (const rule of rules) {
const match = /^Attachment\s+(\d+)$/i.exec(getText(rule, "label").trim());
if (match) highest = Math.max(highest, Number(match[1]));
}
highest = Math.max(highest, rules.length);
return `Attachment ${highest + 1}`;
}
export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
if (!Array.isArray(value)) return [];
return value.filter(isRecord).map((rule) => ({
id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`),
label: getText(rule, "label"),
base_path_id: getText(rule, "base_path_id"),
base_dir: getText(rule, "base_dir", ""),
file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"),
include_subdirs: getBool(rule, "include_subdirs"),
required: getBool(rule, "required", true),
missing_behavior: getText(rule, "missing_behavior", "ask"),
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
}));
}
export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSummary {
return rules.reduce<AttachmentSummary>((summary, rule) => {
if (isDirectAttachmentRule(rule)) {
summary.direct += 1;
} else {
summary.rules += 1;
}
return summary;
}, { direct: 0, rules: 0 });
}
export function countIndividualAttachmentRules(entriesValue: unknown): number {
const entries = asRecord(entriesValue);
return asArray(entries.inline)
.map(asRecord)
.flatMap((entry) => asArray(entry.attachments))
.length;
}
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
const explicitType = getText(rule, "type");
if (explicitType === "direct") return true;
if (explicitType === "pattern") return false;
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
if (!fileFilter) return false;
return !/[{}*?\[\]]/.test(fileFilter);
}

View File

@@ -0,0 +1,232 @@
import type { CampaignListItem } from "../../../types";
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
export type CampaignWorkspaceData = {
campaign: CampaignListItem | null;
versions: CampaignVersionListItem[];
currentVersion: CampaignVersionDetail | null;
summary: CampaignSummary | null;
};
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function asRecord(value: unknown): Record<string, unknown> {
return isRecord(value) ? value : {};
}
export function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
export function getCampaignJson(version: CampaignVersionDetail | null): Record<string, unknown> {
return version?.raw_json ?? version?.campaign_json ?? {};
}
export function getCampaignSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).campaign);
}
export function getRecipientsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).recipients);
}
export function getTemplateSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).template);
}
export function getDeliverySection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).delivery);
}
export function getEntriesSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).entries);
}
export function getAttachmentsSection(version: CampaignVersionDetail | null): Record<string, unknown> {
return asRecord(getCampaignJson(version).attachments);
}
export function getFields(version: CampaignVersionDetail | null): unknown[] {
return asArray(getCampaignJson(version).fields);
}
export function userLockState(
version: CampaignVersionDetail | CampaignVersionListItem | null
): "temporary" | "permanent" | null {
if (!version) return null;
if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") {
return version.user_lock_state;
}
// Backwards compatibility for snapshots created before explicit user locks.
return version.published_at ? "permanent" : null;
}
export function isTemporaryUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) === "temporary";
}
export function isPermanentUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) === "permanent";
}
export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
return userLockState(version) !== null;
}
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false;
return [
"queued",
"sending",
"sent",
"completed",
"partially_completed",
"outcome_unknown",
"failed",
"failed_partial",
"partially_sent",
"archived",
"cancelled"
].includes((version.workflow_state ?? "").toLowerCase());
}
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version?.locked_at || isUserLockedVersion(version)) return false;
const validation = asRecord(version.validation_summary);
return validation.ok === true;
}
export function canUnlockValidationVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
if (!version) return false;
if (isUserLockedVersion(version)) return false;
if (!isVersionReadyForDelivery(version)) return false;
if (isFinalLockedVersion(version)) return false;
return ["approved", "built"].includes(version.workflow_state ?? "");
}
export function isHistoricalCampaignVersion(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): boolean {
return Boolean(version && currentVersionId && version.id !== currentVersionId);
}
export function isAuditLockedVersion(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): boolean {
if (!version) return false;
if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
if (version.locked_at || isUserLockedVersion(version)) return true;
return isFinalLockedVersion(version);
}
export function versionLockReason(
version: CampaignVersionDetail | CampaignVersionListItem | null,
currentVersionId?: string | null,
): string {
if (!version) return "No campaign version is loaded.";
if (isHistoricalCampaignVersion(version, currentVersionId)) {
return "Historical campaign versions are review-only. Continue work in the current version or create a new working copy after the current version becomes immutable.";
}
if (isTemporaryUserLockedVersion(version)) {
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
}
if (isPermanentUserLockedVersion(version)) {
return `Permanently user-locked at ${formatDateTime(version.user_locked_at ?? version.published_at)}. It cannot be unlocked; create an editable copy.`;
}
if (canUnlockValidationVersion(version)) {
return `Temporarily locked by validation at ${formatDateTime(version.locked_at)}. Unlocking invalidates validation, build and queue state.`;
}
if (isFinalLockedVersion(version)) return `Permanently locked by delivery/final state: ${humanize(version.workflow_state ?? "locked")}.`;
if (version.locked_at) return `Temporarily locked at ${formatDateTime(version.locked_at)}.`;
return "Editable working version.";
}
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
if (!version) return "—";
const flow = version.current_flow || "manual";
const step = version.current_step || "not set";
return `${humanize(flow)} / ${humanize(step)}`;
}
export function formatDateTime(value?: string | null): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
export function humanize(value?: string | null): string {
if (!value) return "—";
return value.replace(/_/g, " ").replace(/-/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
export function summaryValue(summary: Record<string, unknown> | null | undefined, keys: string[]): string | number {
if (!summary) return "—";
for (const key of keys) {
const value = summary[key];
if (typeof value === "number" || typeof value === "string") return value;
}
return "—";
}
export function getString(record: Record<string, unknown>, key: string, fallback = "—"): string {
const value = record[key];
if (typeof value === "string" && value.trim()) return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
export function getNestedString(record: Record<string, unknown>, path: string[], fallback = "—"): string {
let current: unknown = record;
for (const part of path) {
if (!isRecord(current)) return fallback;
current = current[part];
}
if (typeof current === "string" && current.trim()) return current;
if (typeof current === "number" || typeof current === "boolean") return String(current);
if (Array.isArray(current)) return current.length ? current.join(", ") : fallback;
if (isRecord(current)) return stringifyPreview(current, 120);
return fallback;
}
export function stringifyPreview(value: unknown, maxLength = 220): string {
const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) ?? "";
return text.length > maxLength ? `${text.slice(0, maxLength)}` : text;
}
export function cloneCampaignJsonForCopy(
source: Record<string, unknown>,
campaign: CampaignListItem | null,
stamp: string
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } {
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
const campaignSection = asRecord(rawJson.campaign);
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");
const baseName = String(campaignSection.name || campaign?.name || "Campaign");
const description = String(campaignSection.description || campaign?.description || "");
const externalId = `${baseId}-copy-${stamp}`.replace(/[^a-zA-Z0-9_.-]+/g, "-").toLowerCase();
const name = `${baseName} (copy)`;
rawJson.campaign = {
...campaignSection,
id: externalId,
name,
description
};
return { externalId, name, description, rawJson };
}
export function timestampSlug(date = new Date()): string {
return date.toISOString().slice(0, 19).replace(/[-:T]/g, "");
}

View File

@@ -0,0 +1,110 @@
import type { CampaignVersionDetail } from "../../../api/campaigns";
import { asRecord, getCampaignJson, isRecord } from "./campaignView";
export type DraftPatch = (draft: Record<string, unknown>) => Record<string, unknown>;
export function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? {})) as T;
}
export function ensureCampaignDraft(version: CampaignVersionDetail | null): Record<string, unknown> {
const raw = cloneJson(getCampaignJson(version));
raw.version = typeof raw.version === "string" ? raw.version : "1";
raw.campaign = {
id: "",
name: "",
description: "",
mode: "draft",
...asRecord(raw.campaign)
};
raw.fields = Array.isArray(raw.fields) ? raw.fields : [];
raw.global_values = isRecord(raw.global_values) ? raw.global_values : {};
raw.server = isRecord(raw.server) ? raw.server : {};
raw.recipients = isRecord(raw.recipients) ? raw.recipients : {};
raw.template = isRecord(raw.template) ? raw.template : { subject: "", text: "" };
raw.attachments = {
base_path: ".",
allow_individual: false,
send_without_attachments: true,
global: [],
missing_behavior: "ask",
ambiguous_behavior: "ask",
...asRecord(raw.attachments)
};
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
raw.validation_policy = {
unsent_attachment_files: "warn",
...asRecord(raw.validation_policy)
};
raw.delivery = isRecord(raw.delivery) ? raw.delivery : {};
raw.status_tracking = isRecord(raw.status_tracking) ? raw.status_tracking : { enabled: true };
return raw;
}
export function updateNested(
draft: Record<string, unknown>,
path: string[],
value: unknown
): Record<string, unknown> {
const next = cloneJson(draft);
let current: Record<string, unknown> = next;
path.forEach((segment, index) => {
if (index === path.length - 1) {
current[segment] = value;
return;
}
const existing = current[segment];
if (!isRecord(existing)) {
current[segment] = {};
}
current = current[segment] as Record<string, unknown>;
});
return next;
}
export function parseJsonTextarea<T>(text: string, fallback: T): { value: T; error: string } {
if (!text.trim()) return { value: fallback, error: "" };
try {
return { value: JSON.parse(text) as T, error: "" };
} catch (error) {
return { value: fallback, error: error instanceof Error ? error.message : String(error) };
}
}
export function stringifyJson(value: unknown): string {
return JSON.stringify(value ?? {}, null, 2);
}
export function getBool(record: Record<string, unknown>, key: string, fallback = false): boolean {
const value = record[key];
return typeof value === "boolean" ? value : fallback;
}
export function getNumber(record: Record<string, unknown>, key: string, fallback = 0): number {
const value = record[key];
return typeof value === "number" ? value : fallback;
}
export function getText(record: Record<string, unknown>, key: string, fallback = ""): string {
const value = record[key];
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return fallback;
}
export function downloadJson(filename: string, data: Record<string, unknown>) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export function safeFileStem(value?: string | null): string {
const stem = (value || "campaign").replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
return stem || "campaign";
}

View File

@@ -0,0 +1,56 @@
import { asArray, asRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
export const fieldTypeOptions = ["string", "integer", "double", "date", "password"] as const;
export type CampaignFieldType = typeof fieldTypeOptions[number];
export type CampaignFieldDefinition = {
name: string;
label: string;
type: CampaignFieldType;
required: boolean;
can_override: boolean;
};
export function normalizeFieldType(value: string | undefined | null): CampaignFieldType {
const normalized = String(value || "string").trim();
return fieldTypeOptions.includes(normalized as CampaignFieldType) ? normalized as CampaignFieldType : "string";
}
export function getDraftFields(draft: Record<string, unknown> | null | undefined): CampaignFieldDefinition[] {
return asArray(draft?.fields)
.map(asRecord)
.map((field) => ({
name: getText(field, "name") || getText(field, "id"),
label: getText(field, "label"),
type: normalizeFieldType(getText(field, "type", "string")),
required: getBool(field, "required"),
can_override: getBool(field, "can_override", true)
}))
.filter((field) => Boolean(field.name));
}
export function humanizeFieldName(value: string): string {
return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
}
export function valueToInputText(value: unknown, fieldType: CampaignFieldType): string {
if (value === undefined || value === null) return "";
if (fieldType === "date" && typeof value === "string") return value.slice(0, 10);
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
export function inputValueToFieldValue(fieldType: CampaignFieldType, value: string): unknown {
if (value === "") return "";
if (fieldType === "integer") {
const numberValue = Number.parseInt(value, 10);
return Number.isFinite(numberValue) ? numberValue : value;
}
if (fieldType === "double") {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : value;
}
return value;
}

View 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, "\\$&");
}