|
|
|
|
@@ -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 {
|
|
|
|
|
AlertTriangle,
|
|
|
|
|
@@ -15,12 +15,15 @@ import {
|
|
|
|
|
} from "lucide-react";
|
|
|
|
|
import type { ApiSettings } from "../../types";
|
|
|
|
|
import { buildVersion, mockSendCampaign, validateVersion } from "../../api/campaigns";
|
|
|
|
|
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
|
|
|
|
|
import Button from "../../components/Button";
|
|
|
|
|
import DataGrid, { type DataGridColumn } from "../../components/table/DataGrid";
|
|
|
|
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
|
|
|
|
import LoadingFrame from "../../components/LoadingFrame";
|
|
|
|
|
import PageTitle from "../../components/PageTitle";
|
|
|
|
|
import StatusBadge from "../../components/StatusBadge";
|
|
|
|
|
import InlineHelp from "../../components/help/InlineHelp";
|
|
|
|
|
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
|
|
|
|
|
import VersionLine from "./components/VersionLine";
|
|
|
|
|
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
|
|
|
|
|
import {
|
|
|
|
|
@@ -34,6 +37,8 @@ import {
|
|
|
|
|
isUserLockedVersion,
|
|
|
|
|
isVersionReadyForDelivery,
|
|
|
|
|
} from "./utils/campaignView";
|
|
|
|
|
import { getBool, getText } from "./utils/draftEditor";
|
|
|
|
|
import { buildTemplatePreviewContext, renderTemplatePreviewText } from "./utils/templatePlaceholders";
|
|
|
|
|
|
|
|
|
|
type FlowState =
|
|
|
|
|
| "complete"
|
|
|
|
|
@@ -57,6 +62,8 @@ type FlowStageDefinition = {
|
|
|
|
|
lockReason?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "";
|
|
|
|
|
|
|
|
|
|
const stateColors: Record<FlowState, string> = {
|
|
|
|
|
complete: "var(--green)",
|
|
|
|
|
warning: "var(--amber)",
|
|
|
|
|
@@ -74,7 +81,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|
|
|
|
const version = data.currentVersion;
|
|
|
|
|
const campaignJson = getCampaignJson(version);
|
|
|
|
|
const entries = asRecord(campaignJson.entries);
|
|
|
|
|
const inlineEntries = asArray(entries.inline);
|
|
|
|
|
const inlineEntries = asArray(entries.inline).map(asRecord);
|
|
|
|
|
const validation = asRecord(version?.validation_summary);
|
|
|
|
|
const build = asRecord(version?.build_summary);
|
|
|
|
|
const cards = data.summary?.cards;
|
|
|
|
|
@@ -83,10 +90,23 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|
|
|
|
const rateLimit = asRecord(delivery.rate_limit);
|
|
|
|
|
const imapAppend = asRecord(delivery.imap_append_sent);
|
|
|
|
|
|
|
|
|
|
const [busy, setBusy] = useState<"validate" | "build" | "mock" | "">("");
|
|
|
|
|
const [busy, setBusy] = useState<WorkflowBusy>("");
|
|
|
|
|
const [message, setMessage] = useState("");
|
|
|
|
|
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 [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 validationOk = validation.ok === true;
|
|
|
|
|
@@ -113,11 +133,20 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|
|
|
|
const finalVersion = isFinalLockedVersion(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 mockSent = numberFrom(mockSend, ["sent_count", "attempted_count"]);
|
|
|
|
|
const mockFailed = numberFrom(mockSend, ["failed_count"]);
|
|
|
|
|
const mockComplete = Boolean(mockResult) && mockFailed === 0;
|
|
|
|
|
const mockPartial = Boolean(mockResult) && mockSent > 0 && mockFailed > 0;
|
|
|
|
|
const mockSkipped = numberFrom(mockSend, ["skipped_count"]);
|
|
|
|
|
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"
|
|
|
|
|
? "running"
|
|
|
|
|
@@ -154,17 +183,19 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|
|
|
|
: "active";
|
|
|
|
|
|
|
|
|
|
const downstreamDeliveryActivity = deliveryQueued || deliveryStarted;
|
|
|
|
|
const inspectionSatisfied = messageReviewComplete || Boolean(mockResult) || downstreamDeliveryActivity;
|
|
|
|
|
const inspectionSatisfied = messageReviewComplete || downstreamDeliveryActivity;
|
|
|
|
|
|
|
|
|
|
const inspectState: FlowState = !hasBuild
|
|
|
|
|
? "locked"
|
|
|
|
|
: busy === "inspect"
|
|
|
|
|
? "running"
|
|
|
|
|
: inspectionSatisfied
|
|
|
|
|
? "complete"
|
|
|
|
|
: "active";
|
|
|
|
|
|
|
|
|
|
const mockState: FlowState = !inspectionSatisfied
|
|
|
|
|
? "locked"
|
|
|
|
|
: busy === "mock"
|
|
|
|
|
: busy === "mock" || busy === "mailbox"
|
|
|
|
|
? "running"
|
|
|
|
|
: mockPartial
|
|
|
|
|
? "partial"
|
|
|
|
|
@@ -233,7 +264,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|
|
|
|
id: "workflow-inspect",
|
|
|
|
|
title: "Inspect built messages",
|
|
|
|
|
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,
|
|
|
|
|
state: inspectState,
|
|
|
|
|
stateLabel: stateLabel(inspectState),
|
|
|
|
|
@@ -243,7 +274,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|
|
|
|
id: "workflow-mock",
|
|
|
|
|
title: "Run mock delivery",
|
|
|
|
|
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,
|
|
|
|
|
state: mockState,
|
|
|
|
|
stateLabel: stateLabel(mockState),
|
|
|
|
|
@@ -288,8 +319,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|
|
|
|
try {
|
|
|
|
|
const result = await validateVersion(settings, version.id, true);
|
|
|
|
|
setMessage(result.ok ? "Validation passed." : "Validation finished with issues. Review the exceptions below.");
|
|
|
|
|
setMessageReviewComplete(false);
|
|
|
|
|
setMockResult(null);
|
|
|
|
|
resetDownstreamReview();
|
|
|
|
|
await reload();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setMessage("");
|
|
|
|
|
@@ -306,9 +336,13 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|
|
|
|
setError("");
|
|
|
|
|
try {
|
|
|
|
|
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);
|
|
|
|
|
setReviewedMessageKeys(new Set());
|
|
|
|
|
setMockResult(null);
|
|
|
|
|
setSelectedMockMessage(null);
|
|
|
|
|
await reload();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
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() {
|
|
|
|
|
if (!version || busy || !messageReviewComplete || deliveryQueued || deliveryStarted) return;
|
|
|
|
|
setBusy("mock");
|
|
|
|
|
setMessage("Running the complete mock-delivery flow…");
|
|
|
|
|
setError("");
|
|
|
|
|
setSelectedMockMessage(null);
|
|
|
|
|
try {
|
|
|
|
|
const response = await mockSendCampaign(settings, campaignId, {
|
|
|
|
|
version_id: version.id,
|
|
|
|
|
@@ -336,7 +402,7 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|
|
|
|
const result = asRecord(response.result ?? response);
|
|
|
|
|
setMockResult(result);
|
|
|
|
|
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) {
|
|
|
|
|
setMessage("");
|
|
|
|
|
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) {
|
|
|
|
|
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 messagesPerMinute = numberFrom(rateLimit, ["messages_per_minute"]);
|
|
|
|
|
const estimatedMinutes = messagesPerMinute > 0 && jobsTotal > 0 ? Math.ceil(jobsTotal / messagesPerMinute) : null;
|
|
|
|
|
const canCompleteInspection = builtReviewRows.length > 0 && reviewedCount > 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<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>}
|
|
|
|
|
|
|
|
|
|
<DismissibleAlert tone="info">
|
|
|
|
|
This is a development layout using the current campaign data and existing actions. The established Review & Send page remains available for comparison.
|
|
|
|
|
This development layout uses the current campaign data and existing actions. The established Review & Send page remains available for comparison.
|
|
|
|
|
</DismissibleAlert>
|
|
|
|
|
|
|
|
|
|
<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}>
|
|
|
|
|
<div className="review-flow-fact-grid">
|
|
|
|
|
<WorkflowFact label="Messages" value={hasBuild ? builtCount : "—"} />
|
|
|
|
|
<WorkflowFact label="Recipients" value={jobsTotal || "—"} />
|
|
|
|
|
<WorkflowFact label="Matched files" value={matchedAttachments || "—"} />
|
|
|
|
|
<WorkflowFact label="Loaded for review" value={builtReviewRows.length || "—"} />
|
|
|
|
|
<WorkflowFact label="Opened" value={reviewedCount} />
|
|
|
|
|
<WorkflowFact label="Attachment issues" value={missingAttachments + ambiguousAttachments} />
|
|
|
|
|
</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">
|
|
|
|
|
<Link className="btn btn-secondary" to="../template">Open template review</Link>
|
|
|
|
|
<Button variant="primary" onClick={() => setMessageReviewComplete((value) => !value)} disabled={!hasBuild}>
|
|
|
|
|
{messageReviewComplete ? "Message review completed" : "Mark message review complete"}
|
|
|
|
|
<div className="button-row compact-actions review-flow-stage-actions">
|
|
|
|
|
<Button onClick={() => void loadBuiltMessages()} disabled={!hasBuild || Boolean(busy)}>
|
|
|
|
|
{busy === "inspect" ? "Loading messages…" : builtReviewRows.length > 0 ? "Reload message review" : "Load message review"}
|
|
|
|
|
</Button>
|
|
|
|
|
<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>
|
|
|
|
|
</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 stage={stages[4]} nextState={stages[5].state}>
|
|
|
|
|
<div className="review-flow-fact-grid">
|
|
|
|
|
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
|
|
|
|
|
<WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} />
|
|
|
|
|
<WorkflowFact label="Real server contacted" value="No" />
|
|
|
|
|
<WorkflowFact label="Mock IMAP" value={mockResult ? "Included" : "Not run"} />
|
|
|
|
|
<WorkflowFact label="Skipped" value={mockResult ? mockSkipped : "—"} />
|
|
|
|
|
<WorkflowFact label="Captured messages" value={mockResult ? mockMailboxMessages.length : "—"} />
|
|
|
|
|
</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">
|
|
|
|
|
<div className="button-row compact-actions review-flow-stage-actions">
|
|
|
|
|
<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"}
|
|
|
|
|
</Button>
|
|
|
|
|
<Link className="btn btn-secondary" to="../mail-settings">Review server settings</Link>
|
|
|
|
|
</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 stage={stages[5]} nextState={stages[6].state}>
|
|
|
|
|
@@ -507,6 +652,33 @@ export default function ReviewSendDevelopmentPage({ settings, campaignId }: { se
|
|
|
|
|
</WorkflowStage>
|
|
|
|
|
</div>
|
|
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
@@ -522,6 +694,7 @@ function WorkflowNavigation({ stages, onSelect }: { stages: FlowStageDefinition[
|
|
|
|
|
"--review-nav-color": stateColors[stage.state],
|
|
|
|
|
"--review-nav-next-color": stateColors[nextStage?.state ?? stage.state],
|
|
|
|
|
} as CSSProperties;
|
|
|
|
|
const showSecondaryState = !["active", "locked"].includes(stage.state);
|
|
|
|
|
return (
|
|
|
|
|
<div className="review-flow-navigation-group" key={stage.id} style={style}>
|
|
|
|
|
<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-copy">
|
|
|
|
|
<strong>{stage.shortTitle}</strong>
|
|
|
|
|
<small>{stage.state === "locked" ? <LockKeyhole size={13} aria-label="Locked" /> : stage.stateLabel}</small>
|
|
|
|
|
<strong>
|
|
|
|
|
<span>{stage.shortTitle}</span>
|
|
|
|
|
{stage.state === "locked" && <LockKeyhole size={12} aria-label="Locked" />}
|
|
|
|
|
</strong>
|
|
|
|
|
{showSecondaryState && <small>{stage.stateLabel}</small>}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
{nextStage && <span className="review-flow-navigation-line" aria-hidden="true" />}
|
|
|
|
|
@@ -572,16 +748,19 @@ function WorkflowStage({
|
|
|
|
|
<header className="card-header review-flow-stage-header">
|
|
|
|
|
<h2>
|
|
|
|
|
<span>{stage.title}</span>
|
|
|
|
|
{locked && <LockKeyhole className="review-flow-title-lock" size={15} aria-label="Locked" />}
|
|
|
|
|
<InlineHelp>{stage.description}</InlineHelp>
|
|
|
|
|
</h2>
|
|
|
|
|
{!locked && (
|
|
|
|
|
<span
|
|
|
|
|
className="review-flow-state-badge"
|
|
|
|
|
data-state={stage.state}
|
|
|
|
|
aria-label={stage.stateLabel}
|
|
|
|
|
title={stage.stateLabel}
|
|
|
|
|
>
|
|
|
|
|
{stage.state === "locked" ? <LockKeyhole size={15} aria-hidden="true" /> : stage.stateLabel}
|
|
|
|
|
{stage.stateLabel}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</header>
|
|
|
|
|
<div className="card-body review-flow-stage-content">{children}</div>
|
|
|
|
|
{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 }) {
|
|
|
|
|
return (
|
|
|
|
|
<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 {
|
|
|
|
|
for (const key of keys) {
|
|
|
|
|
const value = record[key];
|
|
|
|
|
@@ -613,12 +936,23 @@ function numberFrom(record: Record<string, unknown>, keys: string[]): number {
|
|
|
|
|
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 {
|
|
|
|
|
switch (state) {
|
|
|
|
|
case "complete": return "Passed";
|
|
|
|
|
case "warning": return "Warnings";
|
|
|
|
|
case "danger": return "Blocked";
|
|
|
|
|
case "active": return "Next";
|
|
|
|
|
case "active": return "Available";
|
|
|
|
|
case "locked": return "Locked";
|
|
|
|
|
case "running": return "Running";
|
|
|
|
|
case "partial": return "Partial";
|
|
|
|
|
|