Review/Send refinement

This commit is contained in:
2026-06-13 18:08:07 +02:00
parent ed8341b8c1
commit 5937dfe97e
6 changed files with 438 additions and 54 deletions

View File

@@ -33,7 +33,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
setError, setError,
currentStep: "global-settings", currentStep: "global-settings",
unsavedTitle: "Unsaved global settings", unsavedTitle: "Unsaved global settings",
unsavedMessage: "Global settings have unsaved changes. Save them before leaving, or discard them and continue.", unsavedMessage: "Policies have unsaved changes. Save them before leaving, or discard them and continue.",
extraPayload: () => ({ editor_state: editorState }), extraPayload: () => ({ editor_state: editorState }),
onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})), onLoaded: (loadedVersion) => setEditorState(cloneJson(loadedVersion.editor_state ?? {})),
onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState)) onSaved: (savedVersion) => setEditorState(cloneJson(savedVersion.editor_state ?? editorState))
@@ -59,7 +59,7 @@ export default function GlobalSettingsPage({ settings, campaignId }: { settings:
<div className="content-pad workspace-data-page"> <div className="content-pad workspace-data-page">
<div className="page-heading split workspace-heading"> <div className="page-heading split workspace-heading">
<div> <div>
<PageTitle loading={loading}>Global settings</PageTitle> <PageTitle loading={loading}>Policies</PageTitle>
<VersionLine version={version} versions={data.versions} status={saveState} /> <VersionLine version={version} versions={data.versions} status={saveState} />
</div> </div>
<div className="button-row compact-actions"> <div className="button-row compact-actions">

View File

@@ -1,4 +1,4 @@
import { useMemo, useState, type CSSProperties } from "react"; import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { import {
AlertTriangle, AlertTriangle,
@@ -15,12 +15,15 @@ import {
} from "lucide-react"; } from "lucide-react";
import type { ApiSettings } from "../../types"; import type { ApiSettings } from "../../types";
import { buildVersion, mockSendCampaign, validateVersion } from "../../api/campaigns"; import { buildVersion, mockSendCampaign, validateVersion } from "../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import Button from "../../components/Button"; import Button from "../../components/Button";
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";
import LoadingFrame from "../../components/LoadingFrame"; import LoadingFrame from "../../components/LoadingFrame";
import PageTitle from "../../components/PageTitle"; import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge"; import StatusBadge from "../../components/StatusBadge";
import InlineHelp from "../../components/help/InlineHelp"; import InlineHelp from "../../components/help/InlineHelp";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import VersionLine from "./components/VersionLine"; import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData"; import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { import {
@@ -34,6 +37,8 @@ import {
isUserLockedVersion, isUserLockedVersion,
isVersionReadyForDelivery, isVersionReadyForDelivery,
} from "./utils/campaignView"; } from "./utils/campaignView";
import { getBool, getText } from "./utils/draftEditor";
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./utils/templatePlaceholders";
type FlowState = type FlowState =
| "complete" | "complete"
@@ -57,6 +62,8 @@ type FlowStageDefinition = {
lockReason?: string; lockReason?: string;
}; };
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "";
const stateColors: Record<FlowState, string> = { const stateColors: Record<FlowState, string> = {
complete: "var(--green)", complete: "var(--green)",
warning: "var(--amber)", warning: "var(--amber)",
@@ -74,7 +81,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const version = data.currentVersion; const version = data.currentVersion;
const campaignJson = getCampaignJson(version); const campaignJson = getCampaignJson(version);
const entries = asRecord(campaignJson.entries); const entries = asRecord(campaignJson.entries);
const inlineEntries = asArray(entries.inline); const inlineEntries = asArray(entries.inline).map(asRecord);
const validation = asRecord(version?.validation_summary); const validation = asRecord(version?.validation_summary);
const build = asRecord(version?.build_summary); const build = asRecord(version?.build_summary);
const cards = data.summary?.cards; const cards = data.summary?.cards;
@@ -83,10 +90,23 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const rateLimit = asRecord(delivery.rate_limit); const rateLimit = asRecord(delivery.rate_limit);
const imapAppend = asRecord(delivery.imap_append_sent); const imapAppend = asRecord(delivery.imap_append_sent);
const [busy, setBusy] = useState<"validate" | "build" | "mock" | "">(""); const [busy, setBusy] = useState<WorkflowBusy>("");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [messageReviewComplete, setMessageReviewComplete] = useState(false); const [messageReviewComplete, setMessageReviewComplete] = useState(false);
const [reviewResult, setReviewResult] = useState<Record<string, unknown> | null>(null);
const [reviewedMessageKeys, setReviewedMessageKeys] = useState<Set<string>>(() => new Set());
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null); const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
const [selectedMockMessage, setSelectedMockMessage] = useState<MockMailboxMessage | null>(null);
useEffect(() => {
setMessageReviewComplete(false);
setReviewResult(null);
setReviewedMessageKeys(new Set());
setSelectedBuiltIndex(null);
setMockResult(null);
setSelectedMockMessage(null);
}, [version?.id]);
const validationPresent = Object.keys(validation).length > 0; const validationPresent = Object.keys(validation).length > 0;
const validationOk = validation.ok === true; const validationOk = validation.ok === true;
@@ -113,11 +133,20 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const finalVersion = isFinalLockedVersion(version); const finalVersion = isFinalLockedVersion(version);
const userLockedVersion = isUserLockedVersion(version); const userLockedVersion = isUserLockedVersion(version);
const reviewBuild = asRecord(reviewResult?.build);
const builtReviewRows = asArray(reviewBuild.messages).map(asRecord);
const reviewedCount = builtReviewRows.reduce((count, row, index) => count + (reviewedMessageKeys.has(builtMessageKey(row, index)) ? 1 : 0), 0);
const selectedBuiltMessage = selectedBuiltIndex === null ? null : builtReviewRows[selectedBuiltIndex] ?? null;
const mockSend = asRecord(mockResult?.send); const mockSend = asRecord(mockResult?.send);
const mockSent = numberFrom(mockSend, ["sent_count", "attempted_count"]); const mockSent = numberFrom(mockSend, ["sent_count", "attempted_count"]);
const mockFailed = numberFrom(mockSend, ["failed_count"]); const mockFailed = numberFrom(mockSend, ["failed_count"]);
const mockComplete = Boolean(mockResult) && mockFailed === 0; const mockSkipped = numberFrom(mockSend, ["skipped_count"]);
const mockPartial = Boolean(mockResult) && mockSent > 0 && mockFailed > 0; const mockComplete = Boolean(mockResult) && mockSent > 0 && mockFailed === 0 && mockSkipped === 0;
const mockPartial = Boolean(mockResult) && mockSent > 0 && (mockFailed > 0 || mockSkipped > 0);
const mockRows = asArray(mockSend.results).map(asRecord);
const mockMailbox = asRecord(mockResult?.mailbox);
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
const validationState: FlowState = busy === "validate" const validationState: FlowState = busy === "validate"
? "running" ? "running"
@@ -154,17 +183,19 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
: "active"; : "active";
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted; const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
const inspectionSatisfied = messageReviewComplete || Boolean(mockResult) || downstreamDeliveryActivity; const inspectionSatisfied = messageReviewComplete || downstreamDeliveryActivity;
const inspectState: FlowState = !hasBuild const inspectState: FlowState = !hasBuild
? "locked" ? "locked"
: busy === "inspect"
? "running"
: inspectionSatisfied : inspectionSatisfied
? "complete" ? "complete"
: "active"; : "active";
const mockState: FlowState = !inspectionSatisfied const mockState: FlowState = !inspectionSatisfied
? "locked" ? "locked"
: busy === "mock" : busy === "mock" || busy === "mailbox"
? "running" ? "running"
: mockPartial : mockPartial
? "partial" ? "partial"
@@ -233,7 +264,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
id: "workflow-inspect", id: "workflow-inspect",
title: "Inspect built messages", title: "Inspect built messages",
shortTitle: "Inspect", shortTitle: "Inspect",
description: "Review rendered content and the actual files attached to representative recipients.", description: "Review rendered content, recipients, validation state and the exact managed files attached to individual messages.",
icon: MailSearch, icon: MailSearch,
state: inspectState, state: inspectState,
stateLabel: stateLabel(inspectState), stateLabel: stateLabel(inspectState),
@@ -243,7 +274,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
id: "workflow-mock", id: "workflow-mock",
title: "Run mock delivery", title: "Run mock delivery",
shortTitle: "Mock send", shortTitle: "Mock send",
description: "Exercise the delivery path without contacting the real SMTP or IMAP server.", description: "Exercise the delivery path, inspect recipient outcomes and open the captured MIME messages without contacting the real SMTP or IMAP server.",
icon: FlaskConical, icon: FlaskConical,
state: mockState, state: mockState,
stateLabel: stateLabel(mockState), stateLabel: stateLabel(mockState),
@@ -288,8 +319,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
try { try {
const result = await validateVersion(settings, version.id, true); const result = await validateVersion(settings, version.id, true);
setMessage(result.ok ? "Validation passed." : "Validation finished with issues. Review the exceptions below."); setMessage(result.ok ? "Validation passed." : "Validation finished with issues. Review the exceptions below.");
setMessageReviewComplete(false); resetDownstreamReview();
setMockResult(null);
await reload(); await reload();
} catch (err) { } catch (err) {
setMessage(""); setMessage("");
@@ -306,9 +336,13 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
setError(""); setError("");
try { try {
const result = await buildVersion(settings, version.id, true); const result = await buildVersion(settings, version.id, true);
setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`); const review = await requestBuiltMessageReview();
setReviewResult(review);
setMessage(`Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s). The message review is ready below.`);
setMessageReviewComplete(false); setMessageReviewComplete(false);
setReviewedMessageKeys(new Set());
setMockResult(null); setMockResult(null);
setSelectedMockMessage(null);
await reload(); await reload();
} catch (err) { } catch (err) {
setMessage(""); setMessage("");
@@ -318,11 +352,43 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
} }
} }
async function loadBuiltMessages() {
if (!version || busy || !hasBuild) return;
setBusy("inspect");
setMessage("Loading the built-message review…");
setError("");
try {
const result = await requestBuiltMessageReview();
setReviewResult(result);
setMessage(`Loaded ${asArray(asRecord(result.build).messages).length} built message(s) for inspection.`);
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function requestBuiltMessageReview(): Promise<Record<string, unknown>> {
if (!version) throw new Error("No campaign version is available.");
const response = await mockSendCampaign(settings, campaignId, {
version_id: version.id,
send: false,
include_warnings: true,
include_needs_review: true,
append_sent: false,
clear_mailbox: false,
check_files: true,
});
return asRecord(response.result ?? response);
}
async function runMockSend() { async function runMockSend() {
if (!version || busy || !messageReviewComplete || deliveryQueued || deliveryStarted) return; if (!version || busy || !messageReviewComplete || deliveryQueued || deliveryStarted) return;
setBusy("mock"); setBusy("mock");
setMessage("Running the complete mock-delivery flow…"); setMessage("Running the complete mock-delivery flow…");
setError(""); setError("");
setSelectedMockMessage(null);
try { try {
const response = await mockSendCampaign(settings, campaignId, { const response = await mockSendCampaign(settings, campaignId, {
version_id: version.id, version_id: version.id,
@@ -336,7 +402,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const result = asRecord(response.result ?? response); const result = asRecord(response.result ?? response);
setMockResult(result); setMockResult(result);
const sendResult = asRecord(result.send); const sendResult = asRecord(result.send);
setMessage(`Mock delivery finished. Captured ${String(sendResult.sent_count ?? 0)} message(s), failed ${String(sendResult.failed_count ?? 0)}.`); setMessage(`Mock delivery finished. Captured ${String(sendResult.sent_count ?? 0)} message(s), failed ${String(sendResult.failed_count ?? 0)}, skipped ${String(sendResult.skipped_count ?? 0)}.`);
} catch (err) { } catch (err) {
setMessage(""); setMessage("");
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
@@ -345,6 +411,40 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
} }
} }
async function openMockMessage(id: string) {
if (!id || busy === "mailbox") return;
setBusy("mailbox");
setError("");
try {
const response = await getMockMailboxMessage(settings, id);
setSelectedMockMessage(response.message);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
function openBuiltMessage(index: number) {
const row = builtReviewRows[index];
if (!row) return;
setSelectedBuiltIndex(index);
setReviewedMessageKeys((current) => {
const next = new Set(current);
next.add(builtMessageKey(row, index));
return next;
});
}
function resetDownstreamReview() {
setMessageReviewComplete(false);
setReviewResult(null);
setReviewedMessageKeys(new Set());
setSelectedBuiltIndex(null);
setMockResult(null);
setSelectedMockMessage(null);
}
function scrollToStage(id: string) { function scrollToStage(id: string) {
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" }); document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
} }
@@ -354,6 +454,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]); const ambiguousAttachments = numberFrom(attachmentSummary, ["ambiguous_configs"]);
const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]); const messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]);
const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null; const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null;
const canCompleteInspection = builtReviewRows.length > 0 && reviewedCount > 0;
return ( return (
<div className="content-pad workspace-data-page review-flow-development-page"> <div className="content-pad workspace-data-page review-flow-development-page">
@@ -374,7 +475,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
{message && <DismissibleAlert tone="info" resetKey={message} floating>{message}</DismissibleAlert>} {message && <DismissibleAlert tone="info" resetKey={message} floating>{message}</DismissibleAlert>}
<DismissibleAlert tone="info"> <DismissibleAlert tone="info">
This is a development layout using the current campaign data and existing actions. The established Review &amp; Send page remains available for comparison. This development layout uses the current campaign data and existing actions. The established Review &amp; Send page remains available for comparison.
</DismissibleAlert> </DismissibleAlert>
<LoadingFrame loading={loading} label="Loading workflow state…"> <LoadingFrame loading={loading} label="Loading workflow state…">
@@ -443,33 +544,77 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
<WorkflowStage stage={stages[3]} nextState={stages[4].state}> <WorkflowStage stage={stages[3]} nextState={stages[4].state}>
<div className="review-flow-fact-grid"> <div className="review-flow-fact-grid">
<WorkflowFact label="Messages" value={hasBuild ? builtCount : "—"} /> <WorkflowFact label="Messages" value={hasBuild ? builtCount : "—"} />
<WorkflowFact label="Recipients" value={jobsTotal || "—"} /> <WorkflowFact label="Loaded for review" value={builtReviewRows.length || "—"} />
<WorkflowFact label="Matched files" value={matchedAttachments || "—"} /> <WorkflowFact label="Opened" value={reviewedCount} />
<WorkflowFact label="Attachment issues" value={missingAttachments + ambiguousAttachments} /> <WorkflowFact label="Attachment issues" value={missingAttachments + ambiguousAttachments} />
</div> </div>
<p className="muted">Use the Template preview to inspect rendered content and the actual managed files matched for individual recipients.</p> <div className="button-row compact-actions review-flow-stage-actions">
<div className="button-row compact-actions"> <Button onClick={() => void loadBuiltMessages()} disabled={!hasBuild || Boolean(busy)}>
<Link className="btn btn-secondary" to="../template">Open template review</Link> {busy === "inspect" ? "Loading messages…" : builtReviewRows.length > 0 ? "Reload message review" : "Load message review"}
<Button variant="primary" onClick={() => setMessageReviewComplete((value) => !value)} disabled={!hasBuild}> </Button>
{messageReviewComplete ? "Message review completed" : "Mark message review complete"} <Link className="btn btn-secondary" to="../template">Open template editor</Link>
<Button
variant="primary"
onClick={() => setMessageReviewComplete((value) => !value)}
disabled={!canCompleteInspection || downstreamDeliveryActivity}
>
{messageReviewComplete ? "Inspection completed" : "Complete inspection"}
</Button> </Button>
</div> </div>
{builtReviewRows.length > 0 && (
<div className="review-flow-data-section">
<DataGrid
id={`campaign-${campaignId}-workflow-built-messages`}
rows={builtReviewRows}
columns={builtMessageColumns(openBuiltMessage, reviewedMessageKeys)}
getRowKey={builtMessageKey}
emptyText="No built messages are available for review."
className="data-table-wrap data-table compact-table"
/>
<p className="muted small-note">Open at least one message before completing the inspection step. The preview includes rendered content, recipients, issues and exact attachment metadata.</p>
</div>
)}
</WorkflowStage> </WorkflowStage>
<WorkflowStage stage={stages[4]} nextState={stages[5].state}> <WorkflowStage stage={stages[4]} nextState={stages[5].state}>
<div className="review-flow-fact-grid"> <div className="review-flow-fact-grid">
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} /> <WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
<WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} /> <WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} />
<WorkflowFact label="Real server contacted" value="No" /> <WorkflowFact label="Skipped" value={mockResult ? mockSkipped : "—"} />
<WorkflowFact label="Mock IMAP" value={mockResult ? "Included" : "Not run"} /> <WorkflowFact label="Captured messages" value={mockResult ? mockMailboxMessages.length : ""} />
</div> </div>
<p className="muted">This uses the existing mock mailbox and failure-safe delivery path. It never marks the campaign as sent.</p> <div className="button-row compact-actions review-flow-stage-actions">
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !messageReviewComplete || deliveryQueued || deliveryStarted}> <Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !messageReviewComplete || deliveryQueued || deliveryStarted}>
{busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"} {busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"}
</Button> </Button>
<Link className="btn btn-secondary" to="../mail-settings">Review server settings</Link> <Link className="btn btn-secondary" to="../mail-settings">Review server settings</Link>
</div> </div>
{mockResult && (
<div className="review-flow-data-stack">
<section className="review-flow-data-section" aria-labelledby="workflow-mock-results-title">
<h3 id="workflow-mock-results-title">Recipient outcomes</h3>
<DataGrid
id={`campaign-${campaignId}-workflow-mock-results`}
rows={mockRows}
columns={mockSendResultColumns()}
getRowKey={(row, index) => String(row.entry_id ?? row.entry_index ?? index)}
emptyText="No mock delivery results were returned."
className="data-table-wrap data-table compact-table"
/>
</section>
<section className="review-flow-data-section" aria-labelledby="workflow-mock-mailbox-title">
<h3 id="workflow-mock-mailbox-title">Captured mock messages</h3>
<DataGrid
id={`campaign-${campaignId}-workflow-mock-mailbox`}
rows={mockMailboxMessages}
columns={mockMailboxColumns(openMockMessage)}
getRowKey={(row, index) => String(row.id ?? index)}
emptyText="No mock messages were captured in this run."
className="data-table-wrap data-table compact-table"
/>
</section>
</div>
)}
</WorkflowStage> </WorkflowStage>
<WorkflowStage stage={stages[5]} nextState={stages[6].state}> <WorkflowStage stage={stages[5]} nextState={stages[6].state}>
@@ -507,6 +652,33 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
</WorkflowStage> </WorkflowStage>
</div> </div>
</LoadingFrame> </LoadingFrame>
{selectedBuiltMessage && selectedBuiltIndex !== null && (
<BuiltMessagePreview
campaignJson={campaignJson}
entries={inlineEntries}
rows={builtReviewRows}
index={selectedBuiltIndex}
onSelect={openBuiltMessage}
onClose={() => setSelectedBuiltIndex(null)}
/>
)}
{selectedMockMessage && (
<MessagePreviewOverlay
title="Captured mock mail"
subject={selectedMockMessage.subject || "Mock message"}
bodyMode="text"
text={selectedMockMessage.body_preview || ""}
recipientLabel={selectedMockMessage.kind === "imap_append" ? "Mock IMAP append" : "Mock SMTP delivery"}
recipientNote={selectedMockMessage.created_at ? new Date(selectedMockMessage.created_at).toLocaleString() : undefined}
metaItems={mockMessageMetaItems(selectedMockMessage)}
attachments={mockMessageAttachments(selectedMockMessage)}
raw={selectedMockMessage.raw_eml}
rawLabel="Raw MIME"
onClose={() => setSelectedMockMessage(null)}
/>
)}
</div> </div>
); );
} }
@@ -522,6 +694,7 @@ function WorkflowNavigation({ stages, onSelect }: { stages: FlowStageDefinition[
"--review-nav-color": stateColors[stage.state], "--review-nav-color": stateColors[stage.state],
"--review-nav-next-color": stateColors[nextStage?.state ?? stage.state], "--review-nav-next-color": stateColors[nextStage?.state ?? stage.state],
} as CSSProperties; } as CSSProperties;
const showSecondaryState = !["active", "locked"].includes(stage.state);
return ( return (
<div className="review-flow-navigation-group" key={stage.id} style={style}> <div className="review-flow-navigation-group" key={stage.id} style={style}>
<button <button
@@ -533,8 +706,11 @@ function WorkflowNavigation({ stages, onSelect }: { stages: FlowStageDefinition[
> >
<span className="review-flow-navigation-icon"><Icon size={17} strokeWidth={1.8} aria-hidden="true" /></span> <span className="review-flow-navigation-icon"><Icon size={17} strokeWidth={1.8} aria-hidden="true" /></span>
<span className="review-flow-navigation-copy"> <span className="review-flow-navigation-copy">
<strong>{stage.shortTitle}</strong> <strong>
<small>{stage.state === "locked" ? <LockKeyhole size={13} aria-label="Locked" /> : stage.stateLabel}</small> <span>{stage.shortTitle}</span>
{stage.state === "locked" && <LockKeyhole size={12} aria-label="Locked" />}
</strong>
{showSecondaryState && <small>{stage.stateLabel}</small>}
</span> </span>
</button> </button>
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />} {nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
@@ -572,16 +748,19 @@ function WorkflowStage({
<header className="card-header review-flow-stage-header"> <header className="card-header review-flow-stage-header">
<h2> <h2>
<span>{stage.title}</span> <span>{stage.title}</span>
{locked && <LockKeyhole className="review-flow-title-lock" size={15} aria-label="Locked" />}
<InlineHelp>{stage.description}</InlineHelp> <InlineHelp>{stage.description}</InlineHelp>
</h2> </h2>
{!locked && (
<span <span
className="review-flow-state-badge" className="review-flow-state-badge"
data-state={stage.state} data-state={stage.state}
aria-label={stage.stateLabel} aria-label={stage.stateLabel}
title={stage.stateLabel} title={stage.stateLabel}
> >
{stage.state === "locked" ? <LockKeyhole size={15} aria-hidden="true" /> : stage.stateLabel} {stage.stateLabel}
</span> </span>
)}
</header> </header>
<div className="card-body review-flow-stage-content">{children}</div> <div className="card-body review-flow-stage-content">{children}</div>
{locked && ( {locked && (
@@ -595,6 +774,56 @@ function WorkflowStage({
); );
} }
function BuiltMessagePreview({
campaignJson,
entries,
rows,
index,
onSelect,
onClose,
}: {
campaignJson: Record<string, unknown>;
entries: Record<string, unknown>[];
rows: Record<string, unknown>[];
index: number;
onSelect: (index: number) => void;
onClose: () => void;
}) {
const row = rows[index] ?? {};
const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1);
const entry = entries[entryIndex] ?? {};
const template = asRecord(campaignJson.template);
const context = buildTemplatePreviewContext(campaignJson, entry);
const ignoreEmptyFields = getBool(asRecord(campaignJson.validation_policy), "ignore_empty_fields", false);
const html = renderTemplatePreviewText(getText(template, "html"), context, ignoreEmptyFields);
const text = renderTemplatePreviewText(getText(template, "text"), context, ignoreEmptyFields);
const subject = String(row.subject || renderTemplatePreviewText(getText(template, "subject"), context, ignoreEmptyFields) || "No subject");
const issues = asArray(row.issues).map(asRecord);
return (
<MessagePreviewOverlay
title="Built message review"
subject={subject}
bodyMode={html.trim() ? "html" : "text"}
text={text}
html={html}
recipientLabel={formatAddressList(row.to) || `Message ${index + 1}`}
recipientNote={issues.length > 0 ? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues.map((issue) => String(issue.message ?? issue.code ?? "Issue")).join(" · ")}` : "Built without reported issues."}
metaItems={builtMessageMetaItems(row)}
attachments={builtMessageAttachments(row)}
navigation={{
index,
total: rows.length,
onFirst: () => onSelect(0),
onPrevious: () => onSelect(Math.max(0, index - 1)),
onNext: () => onSelect(Math.min(rows.length - 1, index + 1)),
onLast: () => onSelect(rows.length - 1),
}}
onClose={onClose}
/>
);
}
function WorkflowFact({ label, value }: { label: string; value: React.ReactNode }) { function WorkflowFact({ label, value }: { label: string; value: React.ReactNode }) {
return ( return (
<div className="review-flow-fact"> <div className="review-flow-fact">
@@ -604,6 +833,100 @@ function WorkflowFact({ label, value }: { label: string; value: React.ReactNode
); );
} }
function builtMessageColumns(
openMessage: (index: number) => void,
reviewedKeys: Set<string>,
): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) },
{ id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || "—" },
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "validation", header: "Validation", width: 145, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.validation_status ?? "unknown")} />, value: (row) => String(row.validation_status ?? "unknown") },
{ id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, align: "right", value: (row) => String(row.attachment_count ?? asArray(row.attachments).length) },
{ id: "reviewed", header: "Reviewed", width: 110, sortable: true, filterable: true, render: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? <Check size={17} aria-label="Reviewed" /> : <span className="muted"></span>, value: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? "yes" : "no" },
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (_row, index) => <Button onClick={() => openMessage(index)}>Review</Button> },
];
}
function mockSendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "number", header: "#", width: 70, sortable: true, sticky: "start", value: (row, index) => String(row.entry_index ?? index + 1) },
{ id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.status ?? "info")} />, value: (row) => String(row.status ?? "info") },
{ id: "recipient", header: "Recipient", width: 250, resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(row.to) || asArray(row.envelope_recipients).join(", ") || "—" },
{ id: "smtp", header: "SMTP", width: 190, sortable: true, filterable: true, value: (row) => String(row.smtp_message_id ?? row.status ?? "—") },
{ id: "imap", header: "IMAP", width: 190, sortable: true, filterable: true, value: (row) => String(row.imap_message_id ?? row.imap_status ?? "—") },
{ id: "message", header: "Message", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.message ?? "—") },
];
}
function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "kind", header: "Kind", width: 125, sortable: true, filterable: true, sticky: "start", render: (row) => <StatusBadge status={String(row.kind ?? "mock")} />, value: (row) => String(row.kind ?? "mock") },
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "—") },
{ id: "envelope", header: "Envelope / folder", width: 300, resizable: true, filterable: true, value: (row) => `${String(row.envelope_from ?? row.folder ?? "—")}${asArray(row.envelope_recipients).join(", ") || String(row.folder ?? "—")}` },
{ id: "attachments", header: "Attachments", width: 125, sortable: true, filterable: true, align: "right", value: (row) => String(row.attachment_count ?? 0) },
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openMessage(String(row.id ?? ""))}>Review</Button> },
];
}
function builtMessageMetaItems(row: Record<string, unknown>) {
return [
{ label: "From", value: formatSingleAddress(row.from) || "—" },
{ label: "To", value: formatAddressList(row.to) || "—" },
{ label: "CC", value: formatAddressList(row.cc) || "—" },
{ label: "BCC", value: formatAddressList(row.bcc) || "—" },
{ label: "Validation", value: String(row.validation_status ?? "—") },
{ label: "MIME size", value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—" },
];
}
function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAttachment[] {
return asArray(row.attachments).map((value, index) => {
const attachment = asRecord(value);
return {
filename: String(attachment.filename ?? attachment.filename_used ?? attachment.display_path ?? `Attachment ${index + 1}`),
detail: String(attachment.display_path ?? attachment.source_path ?? attachment.label ?? ""),
contentType: stringOrUndefined(attachment.content_type),
sizeBytes: numberOrUndefined(attachment.size_bytes),
};
});
}
function mockMessageMetaItems(message: MockMailboxMessage) {
return [
{ label: "From", value: message.from_header || message.envelope_from || "—" },
{ label: "To", value: message.to_header || message.envelope_recipients?.join(", ") || "—" },
{ label: "Kind", value: message.kind || "—" },
{ label: "Folder", value: message.folder || "—" },
{ label: "Message-ID", value: message.message_id || "—" },
{ label: "Size", value: `${message.size_bytes || 0} bytes` },
];
}
function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] {
return (message.attachments ?? []).map((attachment, index) => ({
filename: attachment.filename || `Attachment ${index + 1}`,
contentType: attachment.content_type || undefined,
sizeBytes: attachment.size_bytes ?? undefined,
}));
}
function builtMessageKey(row: Record<string, unknown>, index: number): string {
return String(row.entry_id ?? row.entry_index ?? index);
}
function formatAddressList(value: unknown): string {
return asArray(value).map(asRecord).map(formatSingleAddress).filter(Boolean).join(", ");
}
function formatSingleAddress(value: unknown): string {
const address = asRecord(value);
const email = String(address.email ?? "").trim();
const name = String(address.name ?? "").trim();
if (name && email) return `${name} <${email}>`;
return email || name;
}
function numberFrom(record: Record<string, unknown>, keys: string[]): number { function numberFrom(record: Record<string, unknown>, keys: string[]): number {
for (const key of keys) { for (const key of keys) {
const value = record[key]; const value = record[key];
@@ -613,12 +936,23 @@ function numberFrom(record: Record<string, unknown>, keys: string[]): number {
return 0; return 0;
} }
function numberOrUndefined(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
return undefined;
}
function stringOrUndefined(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
return value.trim() || undefined;
}
function stateLabel(state: FlowState): string { function stateLabel(state: FlowState): string {
switch (state) { switch (state) {
case "complete": return "Passed"; case "complete": return "Passed";
case "warning": return "Warnings"; case "warning": return "Warnings";
case "danger": return "Blocked"; case "danger": return "Blocked";
case "active": return "Next"; case "active": return "Available";
case "locked": return "Locked"; case "locked": return "Locked";
case "running": return "Running"; case "running": return "Running";
case "partial": return "Partial"; case "partial": return "Partial";

View File

@@ -25,8 +25,8 @@ export default function BreadcrumbBar({ pathname }: { pathname: string }) {
const campaignRouteLabels: Record<string, string> = { const campaignRouteLabels: Record<string, string> = {
data: "Sender & Recipients", data: "Sender & Recipients",
campaign: "Sender & Recipients", campaign: "Sender & Recipients",
settings: "Global settings", settings: "Policies",
"global-settings": "Global settings", "global-settings": "Policies",
fields: "Fields", fields: "Fields",
recipients: "Sender & Recipients", recipients: "Sender & Recipients",
"recipient-data": "Recipient data", "recipient-data": "Recipient data",

View File

@@ -16,12 +16,22 @@ const campaignSubnav: ModuleSubnavGroup<CampaignWorkspaceSection>[] = [
] ]
}, },
{ {
title: "SEND CAMPAIGN", title: "SETTINGS",
items: [ items: [
{ id: "mail-settings", label: "Server settings" }, { id: "mail-settings", label: "Server settings" },
{ id: "global-settings", label: "Global settings" }, { id: "global-settings", label: "Policies" }
]
},
{
title: "SEND",
items: [
{ id: "review", label: "Workflow preview" }, { id: "review", label: "Workflow preview" },
{ id: "send", label: "Review & Send" }, { id: "send", label: "Review & Send" }
]
},
{
title: "REPORT",
items: [
{ id: "report", label: "Report" }, { id: "report", label: "Report" },
{ id: "audit", label: "Audit log" } { id: "audit", label: "Audit log" }
] ]

View File

@@ -1616,7 +1616,9 @@
.review-flow-navigation-track { .review-flow-navigation-track {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
width: max-content;
min-width: max-content; min-width: max-content;
margin: 0 auto;
} }
.review-flow-navigation-group { .review-flow-navigation-group {
@@ -1667,6 +1669,10 @@
} }
.review-flow-navigation-copy strong { .review-flow-navigation-copy strong {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
color: var(--text-strong); color: var(--text-strong);
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
@@ -1687,8 +1693,8 @@
.review-flow-navigation-line { .review-flow-navigation-line {
width: 42px; width: 42px;
height: 1px; height: 2px;
margin: 22px 2px 0; margin: 21px 2px 0;
flex: 0 0 auto; flex: 0 0 auto;
background: linear-gradient(to right, var(--review-nav-color), var(--review-nav-next-color)); background: linear-gradient(to right, var(--review-nav-color), var(--review-nav-next-color));
opacity: .82; opacity: .82;
@@ -1703,7 +1709,7 @@
.review-flow-stage { .review-flow-stage {
display: grid; display: grid;
grid-template-columns: 42px minmax(0, 1fr); grid-template-columns: 42px minmax(0, 1fr);
gap: 10px; gap: 22px;
scroll-margin-top: 92px; scroll-margin-top: 92px;
} }
@@ -1712,7 +1718,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
min-height: 100%; min-height: 100%;
padding-top: 14px; padding-top: 17px;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -1736,7 +1742,7 @@
} }
.review-flow-stage-line { .review-flow-stage-line {
width: 1px; width: 2px;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 54px; min-height: 54px;
margin: 4px 0; margin: 4px 0;
@@ -1767,6 +1773,11 @@
margin: 0; margin: 0;
} }
.review-flow-title-lock {
flex: 0 0 auto;
color: var(--muted);
}
.review-flow-state-badge { .review-flow-state-badge {
--review-badge-color: var(--line-dark); --review-badge-color: var(--line-dark);
display: inline-flex; display: inline-flex;
@@ -1936,6 +1947,35 @@
color: var(--muted); color: var(--muted);
} }
.review-flow-stage-actions {
margin-bottom: 16px;
}
.review-flow-data-stack {
display: grid;
gap: 22px;
margin-top: 18px;
}
.review-flow-data-section {
min-width: 0;
margin-top: 18px;
}
.review-flow-data-section > h3 {
margin: 0 0 10px;
color: var(--text-strong);
font-size: 14px;
}
.review-flow-data-section > .data-grid-shell {
max-height: 420px;
}
.review-flow-data-section > .small-note {
margin: 10px 0 0;
}
@keyframes review-flow-pulse { @keyframes review-flow-pulse {
0%, 100% { box-shadow: 0 0 0 4px var(--bg), 0 4px 12px rgba(48, 49, 53, .10); } 0%, 100% { box-shadow: 0 0 0 4px var(--bg), 0 4px 12px rgba(48, 49, 53, .10); }
50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--review-stage-color) 18%, transparent), 0 4px 12px rgba(48, 49, 53, .10); } 50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--review-stage-color) 18%, transparent), 0 4px 12px rgba(48, 49, 53, .10); }
@@ -1951,7 +1991,7 @@
@media (max-width: 680px) { @media (max-width: 680px) {
.review-flow-stage { .review-flow-stage {
grid-template-columns: 36px minmax(0, 1fr); grid-template-columns: 36px minmax(0, 1fr);
gap: 8px; gap: 14px;
} }
.review-flow-stage-node { .review-flow-stage-node {
width: 34px; width: 34px;

View File

@@ -16,8 +16,8 @@ const campaignSectionContexts: Record<string, Omit<HelpContext, "route">> = {
"mail-settings": { id: "campaign.server-settings", title: "Server settings" }, "mail-settings": { id: "campaign.server-settings", title: "Server settings" },
"server-settings": { id: "campaign.server-settings", title: "Server settings" }, "server-settings": { id: "campaign.server-settings", title: "Server settings" },
mail: { id: "campaign.server-settings", title: "Server settings" }, mail: { id: "campaign.server-settings", title: "Server settings" },
"global-settings": { id: "campaign.global-settings", title: "Global settings" }, "global-settings": { id: "campaign.global-settings", title: "Policies" },
settings: { id: "campaign.global-settings", title: "Global settings" }, settings: { id: "campaign.global-settings", title: "Policies" },
review: { id: "campaign.review-preview", title: "Review & Send workflow preview" }, review: { id: "campaign.review-preview", title: "Review & Send workflow preview" },
send: { id: "campaign.send", title: "Review & Send" }, send: { id: "campaign.send", title: "Review & Send" },
report: { id: "campaign.report", title: "Report" }, report: { id: "campaign.report", title: "Report" },