Mock server workflow - first draft

This commit is contained in:
2026-06-11 11:27:14 +02:00
parent 8791de0959
commit 03c3f5f5c3
15 changed files with 1111 additions and 100 deletions

View File

@@ -131,6 +131,16 @@ export type CampaignSendNowPayload = {
enqueue_imap_task?: boolean;
};
export type CampaignMockSendPayload = {
version_id?: string | null;
send?: boolean;
include_warnings?: boolean;
include_needs_review?: boolean;
append_sent?: boolean;
clear_mailbox?: boolean;
check_files?: boolean;
};
export async function listCampaigns(settings: ApiSettings): Promise<CampaignListItem[]> {
const response = await apiFetch<CampaignListResponse>(settings, "/api/v1/campaigns");
@@ -329,6 +339,18 @@ export async function sendCampaignNow(
});
}
export async function mockSendCampaign(
settings: ApiSettings,
campaignId: string,
payload: CampaignMockSendPayload = {}
): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/mock-send`, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function pauseCampaign(settings: ApiSettings, campaignId: string): Promise<Record<string, unknown>> {
return apiFetch<Record<string, unknown>>(settings, `/api/v1/campaigns/${campaignId}/pause`, { method: "POST" });
}

View File

@@ -73,3 +73,58 @@ export async function listImapFolders(
body: JSON.stringify(payload)
});
}
export type MockMailboxMessage = {
id: string;
kind: "smtp" | "imap_append" | string;
created_at: string;
envelope_from?: string | null;
envelope_recipients?: string[];
subject?: string | null;
from_header?: string | null;
to_header?: string | null;
cc_header?: string | null;
bcc_header?: string | null;
message_id?: string | null;
size_bytes?: number;
body_preview?: string | null;
attachment_count?: number;
folder?: string | null;
raw_eml?: string | null;
headers?: Record<string, string>;
attachments?: Array<{ filename?: string | null; content_type?: string | null; size_bytes?: number }>;
};
export type MockMailboxListResponse = {
messages: MockMailboxMessage[];
};
export type MockMailboxMessageResponse = {
message: MockMailboxMessage;
};
export type MockMailboxFailureConfig = {
fail_next_smtp?: boolean | null;
fail_next_imap?: boolean | null;
smtp_reject_recipients_containing?: string | null;
};
export async function listMockMailboxMessages(settings: ApiSettings, kind?: string): Promise<MockMailboxListResponse> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
return apiFetch<MockMailboxListResponse>(settings, `/api/v1/dev/mailbox/messages${suffix}`);
}
export async function getMockMailboxMessage(settings: ApiSettings, id: string): Promise<MockMailboxMessageResponse> {
return apiFetch<MockMailboxMessageResponse>(settings, `/api/v1/dev/mailbox/messages/${encodeURIComponent(id)}`);
}
export async function clearMockMailboxMessages(settings: ApiSettings): Promise<{ deleted_count: number }> {
return apiFetch<{ deleted_count: number }>(settings, "/api/v1/dev/mailbox/messages", { method: "DELETE" });
}
export async function updateMockMailboxFailures(settings: ApiSettings, payload: MockMailboxFailureConfig): Promise<{ config: MockMailboxFailureConfig }> {
return apiFetch<{ config: MockMailboxFailureConfig }>(settings, "/api/v1/dev/mailbox/failures", {
method: "POST",
body: JSON.stringify(payload)
});
}

View File

@@ -1,19 +1,44 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
type CardProps = {
title?: React.ReactNode;
children: React.ReactNode;
actions?: React.ReactNode;
collapsible?: boolean;
};
export default function Card({ title, children, actions }: CardProps) {
export default function Card({ title, children, actions, collapsible = false }: CardProps) {
const [collapsed, setCollapsed] = useState(false);
const hasHeader = Boolean(title || actions || collapsible);
const body = <div className="card-body">{children}</div>;
const shouldRenderBody = !collapsible || !collapsed;
return (
<section className="card">
{(title || actions) && (
<section className={`card${collapsible ? " card-collapsible" : ""}${collapsed ? " is-collapsed" : ""}`}>
{hasHeader && (
<header className="card-header">
{title && (typeof title === "string" ? <h2>{title}</h2> : <div className="card-title-node">{title}</div>)}
{actions && <div className="card-actions">{actions}</div>}
{(actions || collapsible) && (
<div className="card-actions">
{actions}
{collapsible && (
<button
type="button"
className="card-collapse-toggle"
aria-label={collapsed ? "Show content" : "Show header only"}
aria-expanded={!collapsed}
title={collapsed ? "Show content" : "Show header only"}
onClick={() => setCollapsed((value) => !value)}
>
<ChevronDown size={18} strokeWidth={2.4} aria-hidden="true" />
</button>
)}
</div>
)}
</header>
)}
<div className="card-body">{children}</div>
{shouldRenderBody && (collapsible ? <div className="card-collapse-region">{body}</div> : body)}
</section>
);
}

View File

@@ -101,6 +101,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
<th>Name</th>
<th>Path</th>
<th>Individual attachments</th>
<th>Unsent warning</th>
<th></th>
</tr>
</thead>
@@ -129,6 +130,7 @@ export default function AttachmentsDataPage({ settings, campaignId }: { settings
</div>
</td>
<td><ToggleSwitch label="Individual" checked={Boolean(basePath.allow_individual)} disabled={locked} onChange={(checked) => patchBasePath(index, { allow_individual: checked })} /></td>
<td><ToggleSwitch label="Unsent" checked={Boolean(basePath.unsent_warning)} disabled={locked} onChange={(checked) => patchBasePath(index, { unsent_warning: checked })} /></td>
<td className="table-action-cell"><Button variant="danger" onClick={() => removeBasePath(index)} disabled={locked || basePaths.length <= 1}>Remove</Button></td>
</tr>
))}

View File

@@ -86,7 +86,11 @@ export default function CampaignFieldsPage({ settings, campaignId }: { settings:
setDraft((current) => {
const nextDraft = updateNested(current ?? {}, ["fields"], nextFields);
return updateNested(nextDraft, ["global_values"], nextGlobalValues);
const nextWithGlobalValues = updateNested(nextDraft, ["global_values"], nextGlobalValues);
if (duplicate || !cleanedName || !valueKey || valueKey === cleanedName) {
return nextWithGlobalValues;
}
return migrateEntryFieldValues(nextWithGlobalValues, valueKey, cleanedName);
});
markDirty();
}
@@ -219,6 +223,28 @@ function normalizeFields(value: unknown): CampaignFieldDefinition[] {
}));
}
function migrateEntryFieldValues(draft: Record<string, unknown>, oldName: string, newName: string): Record<string, unknown> {
const entries = asRecord(draft.entries);
const inlineEntries = Array.isArray(entries.inline) ? entries.inline : [];
if (inlineEntries.length === 0) return draft;
const nextInlineEntries = inlineEntries.map((entry) => {
if (!isRecord(entry)) return entry;
const fields = asRecord(entry.fields);
if (!Object.prototype.hasOwnProperty.call(fields, oldName)) return entry;
const nextFields = { ...fields };
if (!Object.prototype.hasOwnProperty.call(nextFields, newName)) {
nextFields[newName] = nextFields[oldName];
}
delete nextFields[oldName];
return { ...entry, fields: nextFields };
});
return updateNested(draft, ["entries", "inline"], nextInlineEntries);
}
function migrateFieldOverridePolicy(draft: Record<string, unknown>, editorState: Record<string, unknown>): Record<string, unknown> {
const overridePolicy = asRecord(editorState.field_overrides);
if (Object.keys(overridePolicy).length === 0) return draft;

View File

@@ -78,6 +78,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
<PolicySelect label="Missing required attachment" value={getText(validationPolicy, "missing_required_attachment", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_required_attachment"], value)} />
<PolicySelect label="Missing optional attachment" value={getText(validationPolicy, "missing_optional_attachment", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_optional_attachment"], value)} />
<PolicySelect label="Ambiguous attachment match" value={getText(validationPolicy, "ambiguous_attachment_match", "ask")} disabled={locked} onChange={(value) => patch(["validation_policy", "ambiguous_attachment_match"], value)} />
<PolicySelect label="Unsent attachment file" value={getText(validationPolicy, "unsent_attachment_files", "warn")} disabled={locked} onChange={(value) => patch(["validation_policy", "unsent_attachment_files"], value)} />
<PolicySelect label="Missing email address" value={getText(validationPolicy, "missing_email", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "missing_email"], value)} options={["block", "drop"]} />
<PolicySelect label="Template error" value={getText(validationPolicy, "template_error", "block")} disabled={locked} onChange={(value) => patch(["validation_policy", "template_error"], value)} options={["block", "drop"]} />
<ToggleSwitch label="Ignore empty fields" checked={getBool(validationPolicy, "ignore_empty_fields")} disabled={locked} onChange={(checked) => patch(["validation_policy", "ignore_empty_fields"], checked)} />

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useRef, useState } from "react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
@@ -8,7 +8,7 @@ import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import ToggleSwitch from "../../components/ToggleSwitch";
import { listImapFolders, testImapSettings, testSmtpSettings, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity } from "../../api/mail";
import { clearMockMailboxMessages, getMockMailboxMessage, listImapFolders, listMockMailboxMessages, testImapSettings, testSmtpSettings, updateMockMailboxFailures, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity, type MockMailboxMessage } from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -21,7 +21,12 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
const [smtpTestResult, setSmtpTestResult] = useState<MailConnectionTestResponse | null>(null);
const [imapTestResult, setImapTestResult] = useState<MailConnectionTestResponse | null>(null);
const [folderResult, setFolderResult] = useState<MailImapFolderListResponse | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | null>(null);
const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | "folders" | "mock" | null>(null);
const [mockMessages, setMockMessages] = useState<MockMailboxMessage[]>([]);
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
const [mockError, setMockError] = useState("");
const [mockSandboxEnabled, setMockSandboxEnabled] = useState(false);
const mockSandboxSnapshot = useRef<Record<string, unknown> | null>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
@@ -137,6 +142,109 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
}
function toggleMockMailSandbox(enabled: boolean) {
if (locked) return;
setMockSandboxEnabled(enabled);
if (enabled) {
mockSandboxSnapshot.current = {
smtp: { ...smtp },
imap: { ...imap },
imap_append_sent: { ...imapAppend }
};
patch(["server", "smtp", "host"], "mock.smtp.local");
patch(["server", "smtp", "port"], 2525);
patch(["server", "smtp", "security"], "plain");
patch(["server", "smtp", "username"], "mock");
patch(["server", "smtp", "password"], "mock");
patch(["server", "imap", "enabled"], true);
patch(["server", "imap", "host"], "mock.imap.local");
patch(["server", "imap", "port"], 1143);
patch(["server", "imap", "security"], "plain");
patch(["server", "imap", "username"], "mock");
patch(["server", "imap", "password"], "mock");
patch(["server", "imap", "sent_folder"], "Sent");
patch(["delivery", "imap_append_sent", "enabled"], true);
patch(["delivery", "imap_append_sent", "folder"], "Sent");
setSmtpTestResult({ ok: true, protocol: "smtp", host: "mock.smtp.local", port: 2525, security: "plain", message: "Temporary mock profile enabled. Turn it off to restore the previous values.", details: { mock: true } });
return;
}
const snapshot = mockSandboxSnapshot.current;
if (snapshot) {
patch(["server", "smtp"], asRecord(snapshot.smtp));
patch(["server", "imap"], asRecord(snapshot.imap));
patch(["delivery", "imap_append_sent"], asRecord(snapshot.imap_append_sent));
}
mockSandboxSnapshot.current = null;
setSmtpTestResult(null);
setImapTestResult(null);
}
async function loadMockMailbox() {
setMailActionState("mock");
setMockError("");
try {
const response = await listMockMailboxMessages(settings);
setMockMessages(response.messages);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function openMockMessage(id: string) {
setMailActionState("mock");
setMockError("");
try {
const response = await getMockMailboxMessage(settings, id);
setSelectedMockMessage(response.message);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function clearMockMailbox() {
setMailActionState("mock");
setMockError("");
try {
await clearMockMailboxMessages(settings);
setMockMessages([]);
setSelectedMockMessage(null);
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function failNextMockSmtp() {
setMailActionState("mock");
setMockError("");
try {
await updateMockMailboxFailures(settings, { fail_next_smtp: true });
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
async function failNextMockImap() {
setMailActionState("mock");
setMockError("");
try {
await updateMockMailboxFailures(settings, { fail_next_imap: true });
} catch (err) {
setMockError(err instanceof Error ? err.message : String(err));
} finally {
setMailActionState(null);
}
}
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
@@ -161,6 +269,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
<section className="form-subsection mail-server-subsection">
<div className="subsection-heading split">
<h3>SMTP login</h3>
<ToggleSwitch label="Mock server settings" checked={mockSandboxEnabled} disabled={locked} onChange={toggleMockMailSandbox} />
</div>
<div className="form-grid compact responsive-form-grid">
<FormField label="Host"><input value={getText(smtp, "host")} disabled={locked} onChange={(event) => patch(["server", "smtp", "host"], event.target.value)} /></FormField>
@@ -206,6 +315,67 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
</div>
</Card>
<Card title="Mock mail sandbox">
<div className="subsection-heading split">
<div>
<h3>Captured messages</h3>
<p className="muted small-note">Use the mock sandbox profile, save this page, then send normally. SMTP deliveries and IMAP Sent appends appear here.</p>
</div>
<div className="button-row compact-actions">
<Button onClick={loadMockMailbox} disabled={mailActionState === "mock"}>{mailActionState === "mock" ? "Loading…" : "Reload mailbox"}</Button>
<Button onClick={failNextMockSmtp} disabled={mailActionState === "mock"}>Fail next SMTP</Button>
<Button onClick={failNextMockImap} disabled={mailActionState === "mock"}>Fail next IMAP</Button>
<Button variant="danger" onClick={clearMockMailbox} disabled={mailActionState === "mock" || mockMessages.length === 0}>Clear</Button>
</div>
</div>
{mockError && <div className="alert danger">{mockError}</div>}
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Kind</th>
<th>Received</th>
<th>Subject</th>
<th>Envelope</th>
<th>Attachments</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{mockMessages.map((message) => (
<tr key={message.id}>
<td><span className="status-badge neutral">{message.kind === "imap_append" ? "IMAP" : "SMTP"}</span></td>
<td>{formatMockDate(message.created_at)}</td>
<td>{message.subject || "—"}</td>
<td>{message.envelope_from || message.folder || "—"} {(message.envelope_recipients || []).join(", ") || message.folder || "—"}</td>
<td>{message.attachment_count ?? 0}</td>
<td><Button onClick={() => openMockMessage(message.id)}>Open</Button></td>
</tr>
))}
{mockMessages.length === 0 && (
<tr><td colSpan={6}>No mock messages captured yet.</td></tr>
)}
</tbody>
</table>
</div>
{selectedMockMessage && (
<div className="mock-message-detail">
<div className="subsection-heading split">
<h3>{selectedMockMessage.subject || "Mock message"}</h3>
<Button onClick={() => setSelectedMockMessage(null)}>Close details</Button>
</div>
<div className="detail-grid">
<div><span className="muted small-note">From</span><strong>{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}</strong></div>
<div><span className="muted small-note">To</span><strong>{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}</strong></div>
<div><span className="muted small-note">Size</span><strong>{selectedMockMessage.size_bytes || 0} bytes</strong></div>
<div><span className="muted small-note">Folder</span><strong>{selectedMockMessage.folder || "—"}</strong></div>
</div>
{selectedMockMessage.body_preview && <pre className="mock-message-preview">{selectedMockMessage.body_preview}</pre>}
{selectedMockMessage.raw_eml && <details><summary>Raw MIME</summary><pre className="mock-message-raw">{selectedMockMessage.raw_eml}</pre></details>}
</div>
)}
</Card>
<div className="button-row page-bottom-actions">
<Button variant="primary" onClick={() => saveDraft("manual")} disabled={!dirty || locked}>Save</Button>
</div>
@@ -216,6 +386,14 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
}
function formatMockDate(value: string): string {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function MailActionResult({ result }: { result: MailConnectionTestResponse | null }) {
if (!result) return null;
const authenticated = result.details?.authenticated;

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
@@ -10,20 +10,59 @@ import Card from "../../components/Card";
import StatusBadge from "../../components/StatusBadge";
import { buildVersion, validateVersion } from "../../api/campaigns";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, formatDateTime, isAuditLockedVersion, isFinalLockedVersion, isUserLockedVersion, isVersionReadyForDelivery, stringifyPreview, summaryValue, versionLockReason } from "./utils/campaignView";
import {
asArray,
asRecord,
formatDateTime,
isAuditLockedVersion,
isFinalLockedVersion,
isUserLockedVersion,
isVersionReadyForDelivery,
stringifyPreview,
summaryValue,
versionLockReason,
} from "./utils/campaignView";
export default function ReviewDataPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
export default function ReviewDataPage({
settings,
campaignId,
}: {
settings: ApiSettings;
campaignId: string;
}) {
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(
settings,
campaignId,
{ includeSummary: true },
);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version);
const auditSafe = isUserLockedVersion(version) || isFinalLockedVersion(version);
const issues = collectIssues(data.summary?.issues);
const auditSafe =
isUserLockedVersion(version) || isFinalLockedVersion(version);
const validationSummary = asRecord(version?.validation_summary);
const buildSummary = asRecord(version?.build_summary);
const validationOk = validationSummary.ok === true;
const readyForDelivery = isVersionReadyForDelivery(version);
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "" >("");
const [actionBusy, setActionBusy] = useState<"validate" | "build" | "">("");
const [actionMessage, setActionMessage] = useState("");
const [lastValidationResult, setLastValidationResult] = useState<Record<
string,
unknown
> | null>(null);
useEffect(() => {
setLastValidationResult(null);
}, [version?.id]);
const issues = useMemo(
() =>
collectIssues(
lastValidationResult,
validationSummary,
data.summary?.issues,
),
[lastValidationResult, validationSummary, data.summary?.issues],
);
async function runValidate() {
if (!version || actionBusy) return;
@@ -32,7 +71,12 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
setError("");
try {
const result = await validateVersion(settings, version.id, false);
setActionMessage(result.ok ? "Validation passed. This version is now locked but can still be unlocked before sending." : "Validation finished with issues. Fix the campaign and validate again.");
setLastValidationResult(result);
setActionMessage(
result.ok
? "Validation passed. This version is now locked but can still be unlocked before sending."
: "Validation finished with issues. See the validation issues below, fix the campaign, and validate again.",
);
await reload();
} catch (err) {
setActionMessage("");
@@ -49,7 +93,9 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
setError("");
try {
const result = await buildVersion(settings, version.id, true);
setActionMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`);
setActionMessage(
`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`,
);
await reload();
} catch (err) {
setActionMessage("");
@@ -64,76 +110,228 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading || Boolean(actionBusy)}>Review</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
<VersionLine
version={version}
versions={data.versions}
loadedAt={version?.updated_at}
/>
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading || Boolean(actionBusy)}>Reload</Button>
<Link to="../wizard/review"><Button variant="primary">Open Review Wizard</Button></Link>
<Button onClick={reload} disabled={loading || Boolean(actionBusy)}>
Reload
</Button>
<Link to="../wizard/review">
<Button variant="primary">Open Review Wizard</Button>
</Link>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{actionMessage && <div className="alert info">{actionMessage}</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message={auditSafe ? "Audit-safe snapshot. Create a copy to continue." : "Validated snapshot. Unlock before sending, or copy to edit."} />}
{locked && (
<LockedVersionNotice
settings={settings}
campaignId={campaignId}
version={version}
reload={reload}
message={
auditSafe
? "Audit-safe snapshot. Create a copy to continue."
: "Validated snapshot. Unlock before sending, or copy to edit."
}
/>
)}
<LoadingFrame loading={loading} label="Loading review data…">
<Card title="Review actions" actions={<span className="muted small-note">Validation locks this version; unlocking invalidates validation before sending.</span>}>
<Card
title="Review actions"
actions={
<span className="muted small-note">
Validation locks this version; unlocking invalidates validation
before sending.
</span>
}
>
<div className="button-row compact-actions">
<Button variant="primary" onClick={runValidate} disabled={!version || loading || Boolean(actionBusy) || readyForDelivery || auditSafe}>
{actionBusy === "validate" ? "Validating…" : readyForDelivery ? "Validated and locked" : validationOk ? "Validate again" : "Validate and lock"}
<Button
variant="primary"
onClick={runValidate}
disabled={
!version ||
loading ||
Boolean(actionBusy) ||
readyForDelivery ||
auditSafe
}
>
{actionBusy === "validate"
? "Validating…"
: readyForDelivery
? "Validated and locked"
: validationOk
? "Validate again"
: "Validate and lock"}
</Button>
<Button onClick={runBuild} disabled={!version || loading || Boolean(actionBusy) || !readyForDelivery}>
<Button
onClick={runBuild}
disabled={
!version || loading || Boolean(actionBusy) || !readyForDelivery
}
>
{actionBusy === "build" ? "Building…" : "Build queue"}
</Button>
</div>
<dl className="detail-list compact-detail-list">
<div><dt>Version state</dt><dd>{version?.workflow_state ?? "—"}</dd></div>
<div><dt>Lock</dt><dd>{versionLockReason(version)}</dd></div>
<div><dt>Validation</dt><dd>{validationOk ? "Passed" : version?.validation_summary ? "Needs attention" : "Not validated"}</dd></div>
<div><dt>Build</dt><dd>{String(buildSummary.built_count ?? buildSummary.ready_count ?? "Not built")}</dd></div>
<div>
<dt>Version state</dt>
<dd>{version?.workflow_state ?? "—"}</dd>
</div>
<div>
<dt>Lock</dt>
<dd>{versionLockReason(version)}</dd>
</div>
<div>
<dt>Validation</dt>
<dd>
{validationOk
? "Passed"
: version?.validation_summary
? "Needs attention"
: "Not validated"}
</dd>
</div>
<div>
<dt>Build</dt>
<dd>
{String(
buildSummary.built_count ??
buildSummary.ready_count ??
"Not built",
)}
</dd>
</div>
</dl>
</Card>
<div className="dashboard-grid">
<Card title="Validation summary">
<div className="summary-grid">
<SummaryTile label="Errors" value={summaryValue(version?.validation_summary, ["error_count", "errors", "blocked"])} />
<SummaryTile label="Warnings" value={summaryValue(version?.validation_summary, ["warning_count", "warnings"])} />
<SummaryTile label="Info" value={summaryValue(version?.validation_summary, ["info_count", "info"])} />
<SummaryTile label="Validated" value={formatDateTime(version?.updated_at)} />
<SummaryTile
label="Errors"
value={summaryValue(version?.validation_summary, [
"error_count",
"errors",
"blocked",
])}
/>
<SummaryTile
label="Warnings"
value={summaryValue(version?.validation_summary, [
"warning_count",
"warnings",
])}
/>
<SummaryTile
label="Info"
value={summaryValue(version?.validation_summary, [
"info_count",
"info",
])}
/>
<SummaryTile
label="Validated"
value={formatDateTime(version?.updated_at)}
/>
</div>
{!version?.validation_summary && <p className="muted">No validation summary is stored yet.</p>}
{!version?.validation_summary && (
<p className="muted">No validation summary is stored yet.</p>
)}
</Card>
<Card title="Build summary">
<div className="summary-grid">
<SummaryTile label="Built" value={summaryValue(version?.build_summary, ["built_count", "built", "messages_built"])} />
<SummaryTile label="Blocked" value={summaryValue(version?.build_summary, ["blocked_count", "blocked"])} />
<SummaryTile label="Needs review" value={summaryValue(version?.build_summary, ["needs_review_count", "needs_review"])} />
<SummaryTile label="Warnings" value={summaryValue(version?.build_summary, ["warning_count", "warnings"])} />
<SummaryTile
label="Built"
value={summaryValue(version?.build_summary, [
"built_count",
"built",
"messages_built",
])}
/>
<SummaryTile
label="Blocked"
value={summaryValue(version?.build_summary, [
"blocked_count",
"blocked",
])}
/>
<SummaryTile
label="Needs review"
value={summaryValue(version?.build_summary, [
"needs_review_count",
"needs_review",
])}
/>
<SummaryTile
label="Warnings"
value={summaryValue(version?.build_summary, [
"warning_count",
"warnings",
])}
/>
</div>
{!version?.build_summary && <p className="muted">No build summary is stored yet.</p>}
{!version?.build_summary && (
<p className="muted">No build summary is stored yet.</p>
)}
</Card>
</div>
<Card title="Review issues" actions={<span className="muted small-note">Grouped issue display will be expanded in the next review pass.</span>}>
{issues.length === 0 && <p className="muted">No stored issues were returned for this campaign summary.</p>}
<Card
title="Validation issues"
actions={
issues.length > 0 ? (
<span className="muted small-note">{issues.length} issue(s)</span>
) : undefined
}
>
{issues.length === 0 && (
<p className="muted">
No validation issues are stored for this version. Run validation
to populate this list.
</p>
)}
{issues.length > 0 && (
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table">
<thead>
<tr>
<th>Severity</th>
<th>Section</th>
<th>Location</th>
<th>Code</th>
<th>Message</th>
</tr>
</thead>
<tbody>
{issues.map((issue, index) => (
<tr key={index}>
<td><StatusBadge status={String(issue.severity || "info")} /></td>
<td>{String(issue.section || issue.field || "—")}</td>
<td>{String(issue.message || issue.code || stringifyPreview(issue, 180))}</td>
<tr key={String(issue.issueKey ?? index)}>
<td>
<StatusBadge
status={String(issue.severity || "info")}
/>
</td>
<td>
{String(
issue.path ||
issue.source ||
issue.section ||
issue.field ||
"—",
)}
</td>
<td>{String(issue.code || "—")}</td>
<td>
{String(issue.message || stringifyPreview(issue, 220))}
</td>
</tr>
))}
</tbody>
@@ -146,7 +344,13 @@ export default function ReviewDataPage({ settings, campaignId }: { settings: Api
);
}
function SummaryTile({ label, value }: { label: string; value: string | number }) {
function SummaryTile({
label,
value,
}: {
label: string;
value: string | number;
}) {
return (
<div className="summary-tile">
<span>{label}</span>
@@ -155,11 +359,68 @@ function SummaryTile({ label, value }: { label: string; value: string | number }
);
}
function collectIssues(raw: unknown): Record<string, unknown>[] {
function collectIssues(...sources: unknown[]): Record<string, unknown>[] {
const byKey = new Map<string, Record<string, unknown>>();
for (const source of sources) {
for (const issue of collectIssueSource(source)) {
const normalized = normalizeIssue(issue);
const key =
String(normalized.severity ?? "") +
"|" +
String(
normalized.path ??
normalized.source ??
normalized.section ??
normalized.field ??
"",
) +
"|" +
String(normalized.code ?? "") +
"|" +
String(normalized.message ?? "");
byKey.set(key, { ...normalized, issueKey: key });
}
}
return Array.from(byKey.values()).sort(
(left, right) => severityRank(left.severity) - severityRank(right.severity),
);
}
function collectIssueSource(raw: unknown): Record<string, unknown>[] {
if (Array.isArray(raw)) return raw.map(asRecord);
if (!raw || typeof raw !== "object") return [];
const record = raw as Record<string, unknown>;
const direct = asArray(record.items ?? record.issues ?? record.results);
if (direct.length) return direct.map(asRecord);
return Object.entries(record).flatMap(([section, value]) => asArray(value).map((item) => ({ section, ...asRecord(item) })));
return Object.entries(record).flatMap(([section, value]) =>
asArray(value).map((item) => ({ section, ...asRecord(item) })),
);
}
function normalizeIssue(
issue: Record<string, unknown>,
): Record<string, unknown> {
return {
...issue,
severity: issue.severity ?? issue.level ?? issue.tone ?? "info",
path: issue.path ?? issue.source ?? issue.location ?? issue.field,
code: issue.code ?? issue.type,
message: issue.message ?? issue.detail ?? issue.description,
};
}
function severityRank(value: unknown): number {
switch (String(value || "info").toLowerCase()) {
case "error":
case "danger":
case "blocked":
return 0;
case "warning":
case "warn":
return 1;
default:
return 2;
}
}

View File

@@ -10,7 +10,9 @@ import VersionLine from "./components/VersionLine";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import StatusBadge from "../../components/StatusBadge";
import { sendCampaignNow } from "../../api/campaigns";
import ToggleSwitch from "../../components/ToggleSwitch";
import { mockSendCampaign, sendCampaignNow } from "../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asArray, asRecord, getDeliverySection, getNestedString, isAuditLockedVersion, isVersionReadyForDelivery, stringifyPreview, versionLockReason } from "./utils/campaignView";
@@ -27,6 +29,12 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
const [sendMessage, setSendMessage] = useState("");
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
const [mockBusy, setMockBusy] = useState(false);
const [mockMessage, setMockMessage] = useState("");
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
const [mockAppendSent, setMockAppendSent] = useState(true);
const [mockClearFirst, setMockClearFirst] = useState(true);
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
const readyForDelivery = isVersionReadyForDelivery(version);
const hasBuild = Boolean(version?.build_summary);
@@ -61,6 +69,51 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
}
}
async function runMockFlow(send: boolean, includeNeedsReview = false) {
if (!version || mockBusy) return;
setMockBusy(true);
setMockMessage(send ? "Running mock delivery…" : "Validating and building mock delivery plan…");
setMockResult(null);
setSelectedMockMessage(null);
setError("");
try {
const response = await mockSendCampaign(settings, campaignId, {
version_id: version.id,
send,
include_warnings: true,
include_needs_review: includeNeedsReview,
append_sent: mockAppendSent,
clear_mailbox: send && mockClearFirst,
check_files: false
});
const result = asRecord(response.result ?? response);
const sendSection = asRecord(result.send);
setMockResult(result);
setMockMessage(
send
? `Mock send finished. Captured ${String(sendSection.sent_count ?? 0)} SMTP message(s), failed ${String(sendSection.failed_count ?? 0)}, skipped ${String(sendSection.skipped_count ?? 0)}.`
: "Mock review finished. Inspect the steps and job details below before sending."
);
} catch (err) {
setMockMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setMockBusy(false);
}
}
async function openMockMessage(id: string) {
setMockBusy(true);
try {
const response = await getMockMailboxMessage(settings, id);
setSelectedMockMessage(response.message);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setMockBusy(false);
}
}
const queuedOrSending = ["queued", "sending"].includes(data.campaign?.status ?? "") || ["queued", "sending"].includes(version?.workflow_state ?? "");
useEffect(() => {
@@ -70,22 +123,30 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
}, [queuedOrSending, loading, reload, sendBusy]);
const resultRows = asArray(sendResult?.results).map(asRecord);
const mockSteps = asArray(mockResult?.steps).map(asRecord);
const mockBuild = asRecord(mockResult?.build);
const mockMessages = asArray(mockBuild.messages).map(asRecord);
const mockSend = asRecord(mockResult?.send);
const mockRows = asArray(mockSend.results).map(asRecord);
const mockMailbox = asRecord(mockResult?.mailbox);
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
return (
<div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading">
<div>
<PageTitle loading={loading || sendBusy}>Send</PageTitle>
<PageTitle loading={loading || sendBusy || mockBusy}>Send</PageTitle>
<VersionLine version={version} versions={data.versions} loadedAt={version?.updated_at} />
</div>
<div className="button-row compact-actions">
<Button onClick={reload} disabled={loading || sendBusy}>Reload</Button>
<Button onClick={reload} disabled={loading || sendBusy || mockBusy}>Reload</Button>
<Link to="../wizard/send"><Button variant="primary">Open Send Wizard</Button></Link>
</div>
</div>
{error && <div className="alert danger">{error}</div>}
{sendMessage && <div className="alert info">{sendMessage}</div>}
{mockMessage && <div className="alert info">{mockMessage}</div>}
{locked && <LockedVersionNotice settings={settings} campaignId={campaignId} version={version} reload={reload} message="Send snapshot. Copy to edit." />}
<LoadingFrame loading={loading || queuedOrSending} label={queuedOrSending ? "Refreshing send state…" : "Loading send data…"}>
@@ -96,6 +157,123 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
<MetricCard label="Failed" value={cards?.failed ?? "—"} tone="danger" detail="SMTP failures" />
</div>
<Card title="Mock delivery test" collapsible actions={<span className="muted small-note">Temporary sandbox. It never uses the real SMTP/IMAP server and never marks this version sent.</span>}>
<div className="button-row compact-actions">
<Button onClick={() => void runMockFlow(false, false)} disabled={!version || loading || mockBusy}>{mockBusy ? "Working…" : "Review mock steps"}</Button>
<Button variant="primary" onClick={() => void runMockFlow(true, false)} disabled={!version || loading || mockBusy}>Mock-send queueable</Button>
<Button onClick={() => void runMockFlow(true, true)} disabled={!version || loading || mockBusy}>Mock-send incl. review</Button>
</div>
<div className="toggle-row mock-send-options">
<ToggleSwitch label="Clear mock mailbox first" checked={mockClearFirst} disabled={mockBusy} onChange={setMockClearFirst} />
<ToggleSwitch label="Append mock Sent copy" checked={mockAppendSent} disabled={mockBusy} onChange={setMockAppendSent} />
</div>
{mockResult && (
<div className="mock-flow-panel">
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table compact-table">
<thead><tr><th>Step</th><th>Status</th><th>Summary</th></tr></thead>
<tbody>
{mockSteps.map((step) => (
<tr key={String(step.key)}>
<td>{String(step.label ?? step.key)}</td>
<td><StatusBadge status={String(step.status ?? "info")} /></td>
<td>{stringifyPreview(asRecord(step.summary), 240)}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3>Built messages</h3>
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table compact-table">
<thead><tr><th>#</th><th>Recipient</th><th>Subject</th><th>Validation</th><th>Attachments</th><th>Issues</th></tr></thead>
<tbody>
{mockMessages.map((message) => {
const to = asArray(message.to).map(asRecord);
const issues = asArray(message.issues).map(asRecord);
return (
<tr key={String(message.entry_index ?? message.entry_id)}>
<td>{String(message.entry_index ?? "—")}</td>
<td>{to.map((item) => String(item.email ?? "")).filter(Boolean).join(", ") || "—"}</td>
<td>{String(message.subject ?? "—")}</td>
<td><StatusBadge status={String(message.validation_status ?? "unknown")} /></td>
<td>{String(message.attachment_count ?? 0)}</td>
<td>{issues.length === 0 ? "—" : issues.map((issue) => String(issue.message ?? issue.code ?? "issue")).join(" · ")}</td>
</tr>
);
})}
{mockMessages.length === 0 && <tr><td colSpan={6}>No messages were built.</td></tr>}
</tbody>
</table>
</div>
{mockRows.length > 0 && (
<>
<h3>Mock send results</h3>
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table compact-table">
<thead><tr><th>#</th><th>Status</th><th>Recipient</th><th>SMTP</th><th>IMAP</th><th>Message</th></tr></thead>
<tbody>
{mockRows.map((row, index) => {
const to = asArray(row.to).map(asRecord);
return (
<tr key={index}>
<td>{String(row.entry_index ?? index + 1)}</td>
<td><StatusBadge status={String(row.status ?? "info")} /></td>
<td>{to.map((item) => String(item.email ?? "")).filter(Boolean).join(", ") || "—"}</td>
<td>{String(row.smtp_message_id ?? row.status ?? "—")}</td>
<td>{String(row.imap_message_id ?? row.imap_status ?? "—")}</td>
<td>{String(row.message ?? "—")}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
)}
<h3>Mock mailbox</h3>
<div className="app-table-wrap data-table-wrap">
<table className="app-table data-table compact-table">
<thead><tr><th>Kind</th><th>Subject</th><th>Envelope / folder</th><th>Attachments</th><th>Actions</th></tr></thead>
<tbody>
{mockMailboxMessages.map((message) => (
<tr key={String(message.id)}>
<td><span className="status-badge neutral">{String(message.kind ?? "mock")}</span></td>
<td>{String(message.subject ?? "—")}</td>
<td>{String(message.envelope_from ?? message.folder ?? "—")} {asArray(message.envelope_recipients).join(", ") || String(message.folder ?? "—")}</td>
<td>{String(message.attachment_count ?? 0)}</td>
<td><Button onClick={() => void openMockMessage(String(message.id))}>Open</Button></td>
</tr>
))}
{mockMailboxMessages.length === 0 && <tr><td colSpan={5}>No mock messages captured in this run.</td></tr>}
</tbody>
</table>
</div>
{selectedMockMessage && (
<div className="mock-message-detail">
<div className="subsection-heading split">
<h3>{selectedMockMessage.subject || "Mock message"}</h3>
<Button onClick={() => setSelectedMockMessage(null)}>Close details</Button>
</div>
<div className="detail-grid">
<div><span className="muted small-note">From</span><strong>{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}</strong></div>
<div><span className="muted small-note">To</span><strong>{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}</strong></div>
<div><span className="muted small-note">Size</span><strong>{selectedMockMessage.size_bytes || 0} bytes</strong></div>
<div><span className="muted small-note">Folder</span><strong>{selectedMockMessage.folder || "—"}</strong></div>
</div>
{selectedMockMessage.body_preview && <pre className="mock-message-preview">{selectedMockMessage.body_preview}</pre>}
{selectedMockMessage.raw_eml && <details><summary>Raw MIME</summary><pre className="mock-message-raw">{selectedMockMessage.raw_eml}</pre></details>}
</div>
)}
</div>
)}
</Card>
<Card title="Send campaign" actions={<span className="muted small-note">Requires a validated, locked and built version. Sending makes it final.</span>}>
{!readyForDelivery && <div className="alert warning compact-alert">Validate and lock this version in Review before dry-run or sending.</div>}
{readyForDelivery && !hasBuild && <div className="alert warning compact-alert">Build the queue in Review before dry-run or sending.</div>}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
import type { ApiSettings } from "../../types";
import Button from "../../components/Button";
import Card from "../../components/Card";
@@ -11,6 +12,7 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { cloneJson, getBool, getText } from "./utils/draftEditor";
import { buildEffectiveAttachmentPreviews, type EffectiveAttachmentPreview } from "./utils/attachments";
import { humanizeFieldName } from "./utils/fieldDefinitions";
import { buildTemplatePreviewContext, buildUndefinedPlaceholders, extractTemplatePlaceholders, removePlaceholderFromText, renderTemplatePreviewText, uniquePlaceholders, valueToPreview, type TemplateNamespace, type TemplatePlaceholder, type UndefinedPlaceholder } from "./utils/templatePlaceholders";
@@ -61,6 +63,7 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
const previewSubject = renderTemplatePreviewText(getText(template, "subject"), previewContext, ignoreEmptyFields);
const previewText = renderTemplatePreviewText(getText(template, "text"), previewContext, ignoreEmptyFields);
const previewHtml = renderTemplatePreviewText(getText(template, "html"), previewContext, ignoreEmptyFields);
const previewAttachments = useMemo(() => buildEffectiveAttachmentPreviews(displayDraft, previewEntry), [displayDraft, previewEntry]);
useEffect(() => {
@@ -239,9 +242,12 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
text={previewText}
html={previewHtml}
hasRealRecipients={inlineEntries.length > 0}
attachments={previewAttachments}
onClose={() => setPreviewOpen(false)}
onFirst={() => setPreviewIndex(0)}
onPrevious={() => setPreviewIndex((value) => Math.max(0, value - 1))}
onNext={() => setPreviewIndex((value) => Math.min(previewEntries.length - 1, value + 1))}
onLast={() => setPreviewIndex(previewEntries.length - 1)}
/>
)}
@@ -319,9 +325,12 @@ function TemplatePreviewOverlay({
text,
html,
hasRealRecipients,
attachments,
onClose,
onFirst,
onPrevious,
onNext
onNext,
onLast
}: {
bodyMode: BodyMode;
entry: Record<string, unknown>;
@@ -331,9 +340,12 @@ function TemplatePreviewOverlay({
text: string;
html: string;
hasRealRecipients: boolean;
attachments: EffectiveAttachmentPreview[];
onClose: () => void;
onFirst: () => void;
onPrevious: () => void;
onNext: () => void;
onLast: () => void;
}) {
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="template-preview-title">
@@ -348,9 +360,12 @@ function TemplatePreviewOverlay({
<strong>{hasRealRecipients ? recipientLabel(entry, index) : "Global preview"}</strong>
<p className="muted small-note">{hasRealRecipients ? `${index + 1} of ${total}` : "No inline recipients are available yet."}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={onPrevious} disabled={index <= 0}>Previous</Button>
<Button onClick={onNext} disabled={index >= total - 1}>Next</Button>
<div className="button-row compact-actions template-preview-nav" aria-label="Preview message navigation">
<button type="button" className="version-arrow" onClick={onFirst} disabled={index <= 0} title="First message" aria-label="First message"><ArrowBigLeftDash aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={onPrevious} disabled={index <= 0} title="Previous message" aria-label="Previous message"><ArrowBigLeft aria-hidden="true" /></button>
<span className="template-preview-count">{index + 1} / {total}</span>
<button type="button" className="version-arrow" onClick={onNext} disabled={index >= total - 1} title="Next message" aria-label="Next message"><ArrowBigRight aria-hidden="true" /></button>
<button type="button" className="version-arrow" onClick={onLast} disabled={index >= total - 1} title="Last message" aria-label="Last message"><ArrowBigRightDash aria-hidden="true" /></button>
</div>
</div>
<div className="template-preview-box">
@@ -361,6 +376,7 @@ function TemplatePreviewOverlay({
<pre>{text || "No plain-text body to preview."}</pre>
)}
</div>
<EffectiveAttachmentPreviewTable attachments={attachments} />
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
@@ -368,6 +384,45 @@ function TemplatePreviewOverlay({
);
}
function EffectiveAttachmentPreviewTable({ attachments }: { attachments: EffectiveAttachmentPreview[] }) {
return (
<div className="template-preview-attachments">
<h3>Effective attachments</h3>
{attachments.length === 0 ? (
<p className="muted small-note">No global or recipient attachments are effective for this message.</p>
) : (
<div className="app-table-wrap">
<table className="app-table compact-table">
<thead>
<tr>
<th>Scope</th>
<th>Label</th>
<th>Base path</th>
<th>File / pattern</th>
<th>Options</th>
<th>Preview match</th>
</tr>
</thead>
<tbody>
{attachments.map((attachment, index) => (
<tr key={`${attachment.scope}:${attachment.basePath}:${attachment.fileFilter}:${index}`}>
<td>{attachment.scope === "global" ? "Global" : "Recipient"}</td>
<td>{attachment.label}</td>
<td>{attachment.basePathName}<br /><span className="muted"><code>{attachment.basePath}</code></span></td>
<td><code>{attachment.fileFilter || "—"}</code></td>
<td>{attachment.required ? "required" : "optional"}{attachment.includeSubdirs ? ", subdirs" : ""}</td>
<td>{attachment.matches.length > 0 ? attachment.matches.join(", ") : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function recipientLabel(entry: Record<string, unknown>, index: number): string {
const name = valueToPreview(entry.name).trim();
const email = valueToPreview(entry.email).trim();

View File

@@ -1,5 +1,6 @@
import { asArray, asRecord, isRecord } from "./campaignView";
import { getBool, getText } from "./draftEditor";
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./templatePlaceholders";
export type AttachmentRule = Record<string, unknown>;
@@ -9,6 +10,7 @@ export type AttachmentBasePath = {
path: string;
source?: string;
allow_individual?: boolean;
unsent_warning?: boolean;
};
export type AttachmentSummary = {
@@ -16,6 +18,17 @@ export type AttachmentSummary = {
rules: number;
};
export type EffectiveAttachmentPreview = {
scope: "global" | "recipient";
label: string;
basePathName: string;
basePath: string;
fileFilter: string;
required: boolean;
includeSubdirs: boolean;
matches: string[];
};
export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
label: string;
};
@@ -41,7 +54,8 @@ export function createAttachmentBasePath(name = "New attachment source", path =
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
name,
path,
allow_individual: false
allow_individual: false,
unsent_warning: false
};
}
@@ -63,7 +77,8 @@ export function normalizeAttachmentBasePaths(value: unknown, attachments: Record
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")
allow_individual: getBool(basePath, "allow_individual"),
unsent_warning: getBool(basePath, "unsent_warning")
}));
}
@@ -71,7 +86,8 @@ export function normalizeAttachmentBasePaths(value: unknown, attachments: Record
id: "base-path-campaign",
name: "Campaign files",
path: getText(attachments, "base_path", "."),
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual)
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
unsent_warning: false
}];
}
@@ -88,7 +104,6 @@ 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)}`),
type: getText(rule, "type"),
label: getText(rule, "label"),
base_dir: getText(rule, "base_dir", ""),
file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"),
@@ -127,3 +142,67 @@ export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
if (!fileFilter) return false;
return !/[{}*?\[\]]/.test(fileFilter);
}
export function buildEffectiveAttachmentPreviews(draft: Record<string, unknown> | null, entry: Record<string, unknown>): EffectiveAttachmentPreview[] {
const attachments = asRecord(draft?.attachments);
const basePaths = normalizeAttachmentBasePaths(attachments.base_paths, attachments);
const context = buildTemplatePreviewContext(draft, entry);
const includeGlobals = getBool(entry, "combine_attachments", true);
const globalRules = includeGlobals ? normalizeAttachmentRules(attachments.global) : [];
const entryRules = normalizeAttachmentRules(entry.attachments);
const individualPaths = basePaths.filter((basePath) => basePath.allow_individual);
const legacyIndividualAllowed = getBool(attachments, "allow_individual", individualPaths.length === 0);
const items: EffectiveAttachmentPreview[] = [];
for (const rule of globalRules) {
items.push(ruleToAttachmentPreview("global", rule, basePaths, context));
}
for (const rule of entryRules) {
const basePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? ".");
const allowed = individualPaths.length > 0
? individualPaths.some((basePath) => basePath.path === basePathValue)
: legacyIndividualAllowed;
if (allowed) items.push(ruleToAttachmentPreview("recipient", rule, basePaths, context));
}
return items;
}
function ruleToAttachmentPreview(
scope: EffectiveAttachmentPreview["scope"],
rule: AttachmentRule,
basePaths: AttachmentBasePath[],
context: Record<string, string>
): EffectiveAttachmentPreview {
const basePathValue = getText(rule, "base_dir", basePaths[0]?.path ?? ".");
const basePath = basePaths.find((item) => item.path === basePathValue);
const fileFilterTemplate = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
const fileFilter = renderTemplatePreviewText(fileFilterTemplate, context, true);
return {
scope,
label: getText(rule, "label") || (scope === "global" ? "Global attachment" : "Recipient attachment"),
basePathName: basePath?.name || basePathValue || "Campaign files",
basePath: basePath?.path || basePathValue || ".",
fileFilter,
required: getBool(rule, "required", true),
includeSubdirs: getBool(rule, "include_subdirs"),
matches: previewFileMatches(fileFilter)
};
}
function previewFileMatches(fileFilter: string): string[] {
const value = fileFilter.trim();
if (!value) return [];
if (!/[{}*?\[\]]/.test(value)) {
return mockAttachmentFiles.filter((file) => file === value || file.endsWith(`/${value}`));
}
const pattern = globLikePatternToRegExp(value);
return mockAttachmentFiles.filter((file) => pattern.test(file));
}
function globLikePatternToRegExp(value: string): RegExp {
const escaped = value.replace(/[.+^${}()|\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".");
return new RegExp(`^${escaped}$`);
}

View File

@@ -32,7 +32,10 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
...asRecord(raw.attachments)
};
raw.entries = isRecord(raw.entries) ? raw.entries : { inline: [] };
raw.validation_policy = isRecord(raw.validation_policy) ? raw.validation_policy : {};
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;

View File

@@ -80,6 +80,13 @@ export function buildTemplatePreviewContext(draft: Record<string, unknown> | nul
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);

View File

@@ -664,6 +664,36 @@
width: min(620px, 100%);
}
.template-preview-nav {
align-items: center;
gap: 0.25rem;
}
.template-preview-count {
color: var(--muted);
margin: 0 0.4rem;
white-space: nowrap;
}
.template-preview-attachments {
margin-top: 16px;
}
.template-preview-attachments h3 {
margin: 0 0 8px;
}
.template-preview-attachments .app-table-wrap {
max-height: 220px;
overflow: scroll;
margin-top: 10px;
}
.template-preview-attachments code {
white-space: nowrap;
}
@media (max-width: 720px) {
.template-preview-toolbar {
display: grid;
@@ -802,7 +832,9 @@
.attachment-sources-table th:nth-child(2),
.attachment-sources-table td:nth-child(2) { min-width: 200px; }
.attachment-sources-table th:nth-child(3),
.attachment-sources-table td:nth-child(3) { width: 207px; }
.attachment-sources-table td:nth-child(3),
.attachment-sources-table th:nth-child(4),
.attachment-sources-table td:nth-child(4) { width: 170px; }
.attachment-sources-table th:last-child,
.attachment-sources-table td:last-child { width: 123px; }
@@ -1112,7 +1144,7 @@
flex-wrap: nowrap;
justify-content: flex-end;
}
.version-line button.version-arrow {
.version-arrow:is(button) {
border: 0;
background: transparent;
padding: 0;
@@ -1120,7 +1152,37 @@
font: inherit;
}
.version-arrow:is(button):disabled {
opacity: 0.24;
pointer-events: none;
cursor: default;
}
/* Overview version history refinements. */
.version-history-table .current-version-row td {
font-weight: 700;
}
.mock-message-detail {
margin-top: 16px;
border-top: 1px solid var(--line-subtle);
padding-top: 16px;
}
.mock-message-preview,
.mock-message-raw {
width: 100%;
max-height: 260px;
overflow: auto;
border: 1px solid var(--line-subtle);
border-radius: 12px;
background: rgba(255, 255, 255, 0.62);
color: var(--text-primary);
padding: 12px;
white-space: pre-wrap;
}
.mock-message-raw {
max-height: 420px;
font-size: 0.82rem;
}

View File

@@ -550,3 +550,60 @@
justify-content: flex-end;
margin-top: 1.1rem;
}
/* Collapsible cards */
.card-collapsible .card-header {
gap: 12px;
}
.card-collapsible .card-actions {
align-items: center;
}
.card-collapse-toggle {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 1px solid #c9c3b9;
border-radius: 999px;
background: linear-gradient(#ffffff, #f1efeb);
color: #4f4a43;
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255,255,255,.75), 0 1px 1px rgba(0,0,0,.05);
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
}
.card-collapse-toggle:hover {
border-color: #aaa299;
background: linear-gradient(#ffffff, #e8e5df);
box-shadow: inset 0 1px 0 rgba(255,255,255,.82), 0 2px 5px rgba(0,0,0,.08);
}
.card-collapse-toggle:focus-visible {
outline: 3px solid rgba(82, 130, 177, .22);
outline-offset: 2px;
}
.card-collapse-toggle svg {
transition: transform .22s ease;
}
.card-collapsible.is-collapsed .card-collapse-toggle svg {
transform: rotate(-180deg);
}
.card-collapse-region {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows .24s ease, opacity .18s ease;
}
.card-collapsible.is-collapsed .card-collapse-region {
display: none;
}
.card-collapse-region > .card-body {
min-height: 0;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.card-collapse-toggle,
.card-collapse-toggle svg,
.card-collapse-region {
transition: none;
}
}