diff --git a/src/features/campaigns/CampaignWorkspace.tsx b/src/features/campaigns/CampaignWorkspace.tsx
index 367d4e3..f93ac3c 100644
--- a/src/features/campaigns/CampaignWorkspace.tsx
+++ b/src/features/campaigns/CampaignWorkspace.tsx
@@ -9,7 +9,6 @@ import RecipientDetailsPage from "./RecipientDetailsPage";
import TemplateDataPage from "./TemplateDataPage";
import AttachmentsDataPage from "./AttachmentsDataPage";
import MailSettingsPage from "./MailSettingsPage";
-import ReviewDataPage from "./ReviewDataPage";
import SendDataPage from "./SendDataPage";
import CreateWizard from "./wizard/CreateWizard";
import ReviewWizard from "./wizard/ReviewWizard";
@@ -74,7 +73,6 @@ function CampaignWorkspaceInner({ settings }: { settings: ApiSettings }) {
} />
} />
} />
- } />
} />
} />
} />
diff --git a/src/features/campaigns/MailSettingsPage.tsx b/src/features/campaigns/MailSettingsPage.tsx
index f512fd5..ddb0083 100644
--- a/src/features/campaigns/MailSettingsPage.tsx
+++ b/src/features/campaigns/MailSettingsPage.tsx
@@ -7,6 +7,7 @@ import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
+import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import ToggleSwitch from "../../components/ToggleSwitch";
import { clearMockMailboxMessages, getMockMailboxMessage, listImapFolders, listMockMailboxMessages, testImapSettings, testSmtpSettings, updateMockMailboxFailures, type MailConnectionTestResponse, type MailImapFolderListResponse, type MailSecurity, type MockMailboxMessage } from "../../api/mail";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
@@ -358,22 +359,7 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
- {selectedMockMessage && (
-
-
-
{selectedMockMessage.subject || "Mock message"}
-
-
-
-
From{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}
-
To{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}
-
Size{selectedMockMessage.size_bytes || 0} bytes
-
Folder{selectedMockMessage.folder || "—"}
-
- {selectedMockMessage.body_preview &&
{selectedMockMessage.body_preview}}
- {selectedMockMessage.raw_eml &&
Raw MIME
{selectedMockMessage.raw_eml}}
-
- )}
+
@@ -381,11 +367,46 @@ export default function MailSettingsPage({ settings, campaignId }: { settings: A
>
+ {selectedMockMessage && (
+ setSelectedMockMessage(null)}
+ />
+ )}
);
}
+
+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 formatMockDate(value: string): string {
if (!value) return "—";
const date = new Date(value);
diff --git a/src/features/campaigns/ReviewDataPage.tsx b/src/features/campaigns/ReviewDataPage.tsx
deleted file mode 100644
index e5e90ab..0000000
--- a/src/features/campaigns/ReviewDataPage.tsx
+++ /dev/null
@@ -1,426 +0,0 @@
-import { useEffect, useMemo, useState } from "react";
-import { Link } from "react-router-dom";
-import type { ApiSettings } from "../../types";
-import Button from "../../components/Button";
-import PageTitle from "../../components/PageTitle";
-import LoadingFrame from "../../components/LoadingFrame";
-import LockedVersionNotice from "./components/LockedVersionNotice";
-import VersionLine from "./components/VersionLine";
-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";
-
-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 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 [actionMessage, setActionMessage] = useState("");
- const [lastValidationResult, setLastValidationResult] = useState | 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;
- setActionBusy("validate");
- setActionMessage("Validating campaign and locking the version on success…");
- setError("");
- try {
- const result = await validateVersion(settings, version.id, false);
- 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("");
- setError(err instanceof Error ? err.message : String(err));
- } finally {
- setActionBusy("");
- }
- }
-
- async function runBuild() {
- if (!version || actionBusy) return;
- setActionBusy("build");
- setActionMessage("Building the queue for the locked, validated version…");
- setError("");
- try {
- const result = await buildVersion(settings, version.id, true);
- setActionMessage(
- `Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`,
- );
- await reload();
- } catch (err) {
- setActionMessage("");
- setError(err instanceof Error ? err.message : String(err));
- } finally {
- setActionBusy("");
- }
- }
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- {error &&
{error}
}
- {actionMessage &&
{actionMessage}
}
- {locked && (
-
- )}
-
-
-
- Validation locks this version; unlocking invalidates validation
- before sending.
-
- }
- >
-
-
-
-
-
-
-
- Version state
- - {version?.workflow_state ?? "—"}
-
-
-
- Lock
- - {versionLockReason(version)}
-
-
-
- Validation
- -
- {validationOk
- ? "Passed"
- : version?.validation_summary
- ? "Needs attention"
- : "Not validated"}
-
-
-
-
- Build
- -
- {String(
- buildSummary.built_count ??
- buildSummary.ready_count ??
- "Not built",
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {!version?.validation_summary && (
- No validation summary is stored yet.
- )}
-
-
-
-
-
-
-
-
-
- {!version?.build_summary && (
- No build summary is stored yet.
- )}
-
-
-
- 0 ? (
- {issues.length} issue(s)
- ) : undefined
- }
- >
- {issues.length === 0 && (
-
- No validation issues are stored for this version. Run validation
- to populate this list.
-
- )}
- {issues.length > 0 && (
-
-
-
-
- | Severity |
- Location |
- Code |
- Message |
-
-
-
- {issues.map((issue, index) => (
-
- |
-
- |
-
- {String(
- issue.path ||
- issue.source ||
- issue.section ||
- issue.field ||
- "—",
- )}
- |
- {String(issue.code || "—")} |
-
- {String(issue.message || stringifyPreview(issue, 220))}
- |
-
- ))}
-
-
-
- )}
-
-
-
- );
-}
-
-function SummaryTile({
- label,
- value,
-}: {
- label: string;
- value: string | number;
-}) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-function collectIssues(...sources: unknown[]): Record[] {
- const byKey = new Map>();
-
- 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[] {
- if (Array.isArray(raw)) return raw.map(asRecord);
- if (!raw || typeof raw !== "object") return [];
- const record = raw as Record;
- 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) })),
- );
-}
-
-function normalizeIssue(
- issue: Record,
-): Record {
- 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;
- }
-}
diff --git a/src/features/campaigns/SendDataPage.tsx b/src/features/campaigns/SendDataPage.tsx
index e682bbe..142db66 100644
--- a/src/features/campaigns/SendDataPage.tsx
+++ b/src/features/campaigns/SendDataPage.tsx
@@ -7,6 +7,8 @@ import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
+import ReviewWorkflowCards from "./components/ReviewWorkflowCards";
+import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import Card from "../../components/Card";
import MetricCard from "../../components/MetricCard";
import StatusBadge from "../../components/StatusBadge";
@@ -157,6 +159,15 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
+
+
Temporary sandbox. It never uses the real SMTP/IMAP server and never marks this version sent.}>
@@ -254,22 +265,7 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
- {selectedMockMessage && (
-
-
-
{selectedMockMessage.subject || "Mock message"}
-
-
-
-
From{selectedMockMessage.from_header || selectedMockMessage.envelope_from || "—"}
-
To{selectedMockMessage.to_header || (selectedMockMessage.envelope_recipients || []).join(", ") || "—"}
-
Size{selectedMockMessage.size_bytes || 0} bytes
-
Folder{selectedMockMessage.folder || "—"}
-
- {selectedMockMessage.body_preview &&
{selectedMockMessage.body_preview}}
- {selectedMockMessage.raw_eml &&
Raw MIME
{selectedMockMessage.raw_eml}}
-
- )}
+
)}
@@ -343,6 +339,21 @@ export default function SendDataPage({ settings, campaignId }: { settings: ApiSe
+ {selectedMockMessage && (
+ setSelectedMockMessage(null)}
+ />
+ )}
);
}
+
+
+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
+ }));
+}
diff --git a/src/features/campaigns/TemplateDataPage.tsx b/src/features/campaigns/TemplateDataPage.tsx
index 3833e6a..4ff2683 100644
--- a/src/features/campaigns/TemplateDataPage.tsx
+++ b/src/features/campaigns/TemplateDataPage.tsx
@@ -1,5 +1,4 @@
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";
@@ -8,6 +7,7 @@ import PageTitle from "../../components/PageTitle";
import LoadingFrame from "../../components/LoadingFrame";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
+import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -233,21 +233,24 @@ export default function TemplateDataPage({ settings, campaignId }: { settings: A
{previewOpen && (
- 0}
- attachments={previewAttachments}
+ recipientLabel={inlineEntries.length > 0 ? recipientLabel(previewEntry, Math.min(previewIndex, previewEntries.length - 1)) : "Global preview"}
+ recipientNote={inlineEntries.length > 0 ? `${Math.min(previewIndex, previewEntries.length - 1) + 1} of ${previewEntries.length}` : "No inline recipients are available yet."}
+ attachments={mapEffectiveAttachmentsToPreviewBoxes(previewAttachments)}
+ navigation={{
+ index: Math.min(previewIndex, previewEntries.length - 1),
+ total: previewEntries.length,
+ 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)
+ }}
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)}
/>
)}
@@ -316,113 +319,31 @@ function UndefinedPlaceholderList({ items, onSelect }: { items: UndefinedPlaceho
);
}
-function TemplatePreviewOverlay({
- bodyMode,
- entry,
- index,
- total,
- subject,
- text,
- html,
- hasRealRecipients,
- attachments,
- onClose,
- onFirst,
- onPrevious,
- onNext,
- onLast
-}: {
- bodyMode: BodyMode;
- entry: Record;
- index: number;
- total: number;
- subject: string;
- text: string;
- html: string;
- hasRealRecipients: boolean;
- attachments: EffectiveAttachmentPreview[];
- onClose: () => void;
- onFirst: () => void;
- onPrevious: () => void;
- onNext: () => void;
- onLast: () => void;
-}) {
- return (
-
-
-
-
-
-
-
{hasRealRecipients ? recipientLabel(entry, index) : "Global preview"}
-
{hasRealRecipients ? `${index + 1} of ${total}` : "No inline recipients are available yet."}
-
-
-
-
-
{index + 1} / {total}
-
-
-
-
-
-
{subject || "No subject"}
- {bodyMode === "html" ? (
-
-
-
-
-
-
- );
+function mapEffectiveAttachmentsToPreviewBoxes(attachments: EffectiveAttachmentPreview[]): MessagePreviewAttachment[] {
+ return attachments.flatMap((attachment) => {
+ const detailParts = [
+ attachment.scope === "global" ? "Global" : "Recipient",
+ attachment.label,
+ attachment.required ? "required" : "optional",
+ attachment.includeSubdirs ? "subdirs" : ""
+ ].filter(Boolean);
+ const detail = detailParts.join(" · ");
+ if (attachment.matches.length > 0) {
+ return attachment.matches.map((match) => ({
+ filename: match,
+ label: attachment.label,
+ detail
+ }));
+ }
+ return [{
+ filename: attachment.fileFilter || "No matched file",
+ label: attachment.label,
+ detail: `${detail}${detail ? " · " : ""}${attachment.basePathName || attachment.basePath || "attachment source"}`
+ }];
+ });
}
-function EffectiveAttachmentPreviewTable({ attachments }: { attachments: EffectiveAttachmentPreview[] }) {
- return (
-
-
Effective attachments
- {attachments.length === 0 ? (
-
No global or recipient attachments are effective for this message.
- ) : (
-
-
-
-
- | Scope |
- Label |
- Base path |
- File / pattern |
- Options |
- Preview match |
-
-
-
- {attachments.map((attachment, index) => (
-
- | {attachment.scope === "global" ? "Global" : "Recipient"} |
- {attachment.label} |
- {attachment.basePathName}
{attachment.basePath} |
- {attachment.fileFilter || "—"} |
- {attachment.required ? "required" : "optional"}{attachment.includeSubdirs ? ", subdirs" : ""} |
- {attachment.matches.length > 0 ? attachment.matches.join(", ") : "—"} |
-
- ))}
-
-
-
- )}
-
- );
-}
-
function recipientLabel(entry: Record, index: number): string {
const name = valueToPreview(entry.name).trim();
const email = valueToPreview(entry.email).trim();
diff --git a/src/features/campaigns/components/MessagePreviewOverlay.tsx b/src/features/campaigns/components/MessagePreviewOverlay.tsx
new file mode 100644
index 0000000..48744e7
--- /dev/null
+++ b/src/features/campaigns/components/MessagePreviewOverlay.tsx
@@ -0,0 +1,143 @@
+import { ArrowBigLeft, ArrowBigLeftDash, ArrowBigRight, ArrowBigRightDash } from "lucide-react";
+import Button from "../../../components/Button";
+
+export type MessagePreviewAttachment = {
+ filename?: string | null;
+ label?: string | null;
+ detail?: string | null;
+ contentType?: string | null;
+ sizeBytes?: number | null;
+};
+
+export type MessagePreviewMetaItem = {
+ label: string;
+ value: React.ReactNode;
+};
+
+export type MessagePreviewNavigation = {
+ index: number;
+ total: number;
+ onFirst: () => void;
+ onPrevious: () => void;
+ onNext: () => void;
+ onLast: () => void;
+};
+
+export type MessagePreviewOverlayProps = {
+ title?: string;
+ subject?: string | null;
+ bodyMode?: "text" | "html";
+ text?: string | null;
+ html?: string | null;
+ recipientLabel?: React.ReactNode;
+ recipientNote?: React.ReactNode;
+ metaItems?: MessagePreviewMetaItem[];
+ attachments?: MessagePreviewAttachment[];
+ raw?: string | null;
+ rawLabel?: string;
+ navigation?: MessagePreviewNavigation;
+ closeLabel?: string;
+ onClose: () => void;
+};
+
+export default function MessagePreviewOverlay({
+ title = "Message preview",
+ subject,
+ bodyMode = "text",
+ text,
+ html,
+ recipientLabel,
+ recipientNote,
+ metaItems = [],
+ attachments = [],
+ raw,
+ rawLabel = "Raw MIME",
+ navigation,
+ closeLabel = "Close",
+ onClose
+}: MessagePreviewOverlayProps) {
+ const shownSubject = subject?.trim() || "No subject";
+ const shownText = text?.trim() || "No plain-text body to preview.";
+ const shownHtml = html?.trim() || "No HTML body to preview.
";
+
+ return (
+
+
+
+
+ {(recipientLabel || recipientNote || navigation) && (
+
+
+ {recipientLabel &&
{recipientLabel}}
+ {recipientNote &&
{recipientNote}
}
+
+ {navigation && (
+
+
+
+
{navigation.index + 1} / {navigation.total}
+
+
+
+ )}
+
+ )}
+
+ {metaItems.length > 0 && (
+
+ {metaItems.map((item) => (
+
{item.label}{item.value || "—"}
+ ))}
+
+ )}
+
+
+
{shownSubject}
+ {bodyMode === "html" ? (
+
+ ) : (
+
{shownText}
+ )}
+
+
+
+
+ {raw && (
+
+ {rawLabel}
+ {raw}
+
+ )}
+
+
+
+
+ );
+}
+
+function MessagePreviewAttachmentBoxes({ attachments }: { attachments: MessagePreviewAttachment[] }) {
+ return (
+
+
Attachments
+ {attachments.length === 0 ? (
+
No attachments are effective for this message.
+ ) : (
+
+ {attachments.map((attachment, index) => {
+ const filename = attachment.filename?.trim() || attachment.label?.trim() || "Unnamed attachment";
+ const details = [attachment.detail, attachment.contentType, attachment.sizeBytes ? `${attachment.sizeBytes} bytes` : ""].filter(Boolean).join(" · ");
+ return (
+
+ {filename}
+ {details && {details}}
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/features/campaigns/components/ReviewWorkflowCards.tsx b/src/features/campaigns/components/ReviewWorkflowCards.tsx
new file mode 100644
index 0000000..4d92b2b
--- /dev/null
+++ b/src/features/campaigns/components/ReviewWorkflowCards.tsx
@@ -0,0 +1,384 @@
+import { useEffect, useMemo, useState } from "react";
+import type { ApiSettings } from "../../../types";
+import type { CampaignSummary, CampaignVersionDetail } from "../../../api/campaigns";
+import { buildVersion, validateVersion } from "../../../api/campaigns";
+import Button from "../../../components/Button";
+import Card from "../../../components/Card";
+import StatusBadge from "../../../components/StatusBadge";
+import {
+ asArray,
+ asRecord,
+ formatDateTime,
+ isFinalLockedVersion,
+ isUserLockedVersion,
+ isVersionReadyForDelivery,
+ stringifyPreview,
+ summaryValue,
+ versionLockReason,
+} from "../utils/campaignView";
+
+type ReviewWorkflowCardsProps = {
+ settings: ApiSettings;
+ version: CampaignVersionDetail | null;
+ summary?: CampaignSummary | null;
+ loading?: boolean;
+ reload: () => Promise | void;
+ setError: (message: string) => void;
+};
+
+export default function ReviewWorkflowCards({
+ settings,
+ version,
+ summary,
+ loading = false,
+ reload,
+ setError,
+}: ReviewWorkflowCardsProps) {
+ 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 [actionMessage, setActionMessage] = useState("");
+ const [lastValidationResult, setLastValidationResult] = useState | null>(null);
+
+ useEffect(() => {
+ setLastValidationResult(null);
+ setActionMessage("");
+ }, [version?.id]);
+
+ const issues = useMemo(
+ () =>
+ collectIssues(
+ lastValidationResult,
+ validationSummary,
+ summary?.issues,
+ ),
+ [lastValidationResult, validationSummary, summary?.issues],
+ );
+
+ async function runValidate() {
+ if (!version || actionBusy) return;
+ setActionBusy("validate");
+ setActionMessage("Validating campaign and locking the version on success…");
+ setError("");
+ try {
+ const result = await validateVersion(settings, version.id, false);
+ 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("");
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setActionBusy("");
+ }
+ }
+
+ async function runBuild() {
+ if (!version || actionBusy) return;
+ setActionBusy("build");
+ setActionMessage("Building the queue for the locked, validated version…");
+ setError("");
+ try {
+ const result = await buildVersion(settings, version.id, true);
+ setActionMessage(
+ `Build finished. Built ${String(result.built_count ?? result.ready_count ?? "—")} message(s).`,
+ );
+ await reload();
+ } catch (err) {
+ setActionMessage("");
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setActionBusy("");
+ }
+ }
+
+ return (
+ <>
+ {actionMessage && {actionMessage}
}
+
+
+ Validation locks this version; unlocking invalidates validation
+ before sending.
+
+ }
+ >
+
+
+
+
+
+
+
- Version state
+ - {version?.workflow_state ?? "—"}
+
+
+
- Lock
+ - {versionLockReason(version)}
+
+
+
- Validation
+ -
+ {validationOk
+ ? "Passed"
+ : version?.validation_summary
+ ? "Needs attention"
+ : "Not validated"}
+
+
+
+
- Build
+ -
+ {String(
+ buildSummary.built_count ??
+ buildSummary.ready_count ??
+ "Not built",
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!version?.validation_summary && (
+ No validation summary is stored yet.
+ )}
+
+
+
+
+
+
+
+
+
+ {!version?.build_summary && (
+ No build summary is stored yet.
+ )}
+
+
+
+ 0 ? (
+ {issues.length} issue(s)
+ ) : undefined
+ }
+ >
+ {issues.length === 0 && (
+
+ No validation issues are stored for this version. Run validation to
+ populate this list.
+
+ )}
+ {issues.length > 0 && (
+
+
+
+
+ | Severity |
+ Location |
+ Code |
+ Message |
+
+
+
+ {issues.map((issue, index) => (
+
+ |
+
+ |
+
+ {String(
+ issue.path ||
+ issue.source ||
+ issue.section ||
+ issue.field ||
+ "—",
+ )}
+ |
+ {String(issue.code || "—")} |
+
+ {String(issue.message || stringifyPreview(issue, 220))}
+ |
+
+ ))}
+
+
+
+ )}
+
+ >
+ );
+}
+
+function SummaryTile({
+ label,
+ value,
+}: {
+ label: string;
+ value: string | number;
+}) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function collectIssues(...sources: unknown[]): Record[] {
+ const byKey = new Map>();
+
+ 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[] {
+ if (Array.isArray(raw)) return raw.map(asRecord);
+ if (!raw || typeof raw !== "object") return [];
+ const record = raw as Record;
+ 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) })),
+ );
+}
+
+function normalizeIssue(
+ issue: Record,
+): Record {
+ 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;
+ }
+}
diff --git a/src/layout/SectionSidebar.tsx b/src/layout/SectionSidebar.tsx
index 900645d..fadd3a5 100644
--- a/src/layout/SectionSidebar.tsx
+++ b/src/layout/SectionSidebar.tsx
@@ -20,8 +20,7 @@ const campaignSubnav: ModuleSubnavGroup[] = [
items: [
{ id: "mail-settings", label: "Server settings" },
{ id: "global-settings", label: "Global settings" },
- { id: "review", label: "Review" },
- { id: "send", label: "Send" },
+ { id: "send", label: "Review & Send" },
{ id: "report", label: "Report" },
{ id: "audit", label: "Audit log" }
]
diff --git a/src/styles/campaign-workspace.css b/src/styles/campaign-workspace.css
index 63d8a87..57bbdc5 100644
--- a/src/styles/campaign-workspace.css
+++ b/src/styles/campaign-workspace.css
@@ -1186,3 +1186,59 @@
max-height: 420px;
font-size: 0.82rem;
}
+
+/* Shared message preview overlay --------------------------------------- */
+.message-preview-modal .modal-body {
+ display: grid;
+ gap: 1rem;
+}
+
+.message-preview-meta {
+ margin: 0;
+}
+
+.message-preview-attachments h3,
+.message-preview-raw summary {
+ margin: 0 0 0.5rem;
+}
+
+.attachment-chip-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.attachment-file-chip {
+ display: inline-flex;
+ flex-direction: column;
+ gap: 0.18rem;
+ min-width: min(220px, 100%);
+ max-width: 100%;
+ border: 1px solid var(--line-subtle);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.68);
+ padding: 0.55rem 0.7rem;
+ color: var(--text-primary);
+}
+
+.attachment-file-chip strong {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.attachment-file-chip span {
+ color: var(--muted);
+ font-size: 0.82rem;
+}
+
+.message-preview-raw {
+ border-top: 1px solid var(--line-subtle);
+ padding-top: 0.75rem;
+}
+
+.message-preview-raw summary {
+ cursor: pointer;
+ font-weight: 700;
+ color: var(--text-primary);
+}