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 (
@@ -604,6 +833,100 @@ function WorkflowFact({ label, value }: { label: string; value: React.ReactNode
);
}
+function builtMessageColumns(
+ openMessage: (index: number) => void,
+ reviewedKeys: Set,
+): DataGridColumn>[] {
+ 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) => , 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)) ? : — , value: (row, index) => reviewedKeys.has(builtMessageKey(row, index)) ? "yes" : "no" },
+ { id: "actions", header: "Actions", width: 110, sticky: "end", render: (_row, index) => openMessage(index)}>Review },
+ ];
+}
+
+function mockSendResultColumns(): DataGridColumn>[] {
+ 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) => , 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): DataGridColumn>[] {
+ return [
+ { id: "kind", header: "Kind", width: 125, sortable: true, filterable: true, sticky: "start", render: (row) => , 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) => void openMessage(String(row.id ?? ""))}>Review },
+ ];
+}
+
+function builtMessageMetaItems(row: Record) {
+ 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): 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, 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, keys: string[]): number {
for (const key of keys) {
const value = record[key];
@@ -613,12 +936,23 @@ function numberFrom(record: Record, 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";
diff --git a/src/layout/BreadcrumbBar.tsx b/src/layout/BreadcrumbBar.tsx
index 5f1f567..3c733ce 100644
--- a/src/layout/BreadcrumbBar.tsx
+++ b/src/layout/BreadcrumbBar.tsx
@@ -25,8 +25,8 @@ export default function BreadcrumbBar({ pathname }: { pathname: string }) {
const campaignRouteLabels: Record = {
data: "Sender & Recipients",
campaign: "Sender & Recipients",
- settings: "Global settings",
- "global-settings": "Global settings",
+ settings: "Policies",
+ "global-settings": "Policies",
fields: "Fields",
recipients: "Sender & Recipients",
"recipient-data": "Recipient data",
diff --git a/src/layout/SectionSidebar.tsx b/src/layout/SectionSidebar.tsx
index a489b05..e8e09fa 100644
--- a/src/layout/SectionSidebar.tsx
+++ b/src/layout/SectionSidebar.tsx
@@ -16,12 +16,22 @@ const campaignSubnav: ModuleSubnavGroup[] = [
]
},
{
- title: "SEND CAMPAIGN",
+ title: "SETTINGS",
items: [
{ 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: "send", label: "Review & Send" },
+ { id: "send", label: "Review & Send" }
+ ]
+ },
+ {
+ title: "REPORT",
+ items: [
{ id: "report", label: "Report" },
{ id: "audit", label: "Audit log" }
]
diff --git a/src/styles/campaign-workspace.css b/src/styles/campaign-workspace.css
index de389f6..a355c00 100644
--- a/src/styles/campaign-workspace.css
+++ b/src/styles/campaign-workspace.css
@@ -1616,7 +1616,9 @@
.review-flow-navigation-track {
display: flex;
align-items: flex-start;
+ width: max-content;
min-width: max-content;
+ margin: 0 auto;
}
.review-flow-navigation-group {
@@ -1667,6 +1669,10 @@
}
.review-flow-navigation-copy strong {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 5px;
color: var(--text-strong);
font-size: 12px;
white-space: nowrap;
@@ -1687,8 +1693,8 @@
.review-flow-navigation-line {
width: 42px;
- height: 1px;
- margin: 22px 2px 0;
+ height: 2px;
+ margin: 21px 2px 0;
flex: 0 0 auto;
background: linear-gradient(to right, var(--review-nav-color), var(--review-nav-next-color));
opacity: .82;
@@ -1703,7 +1709,7 @@
.review-flow-stage {
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
- gap: 10px;
+ gap: 22px;
scroll-margin-top: 92px;
}
@@ -1712,7 +1718,7 @@
flex-direction: column;
align-items: center;
min-height: 100%;
- padding-top: 14px;
+ padding-top: 17px;
box-sizing: border-box;
}
@@ -1736,7 +1742,7 @@
}
.review-flow-stage-line {
- width: 1px;
+ width: 2px;
flex: 1 1 auto;
min-height: 54px;
margin: 4px 0;
@@ -1767,6 +1773,11 @@
margin: 0;
}
+.review-flow-title-lock {
+ flex: 0 0 auto;
+ color: var(--muted);
+}
+
.review-flow-state-badge {
--review-badge-color: var(--line-dark);
display: inline-flex;
@@ -1936,6 +1947,35 @@
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 {
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); }
@@ -1951,7 +1991,7 @@
@media (max-width: 680px) {
.review-flow-stage {
grid-template-columns: 36px minmax(0, 1fr);
- gap: 8px;
+ gap: 14px;
}
.review-flow-stage-node {
width: 34px;
diff --git a/src/utils/helpContext.ts b/src/utils/helpContext.ts
index 90a0fd1..2d33816 100644
--- a/src/utils/helpContext.ts
+++ b/src/utils/helpContext.ts
@@ -16,8 +16,8 @@ const campaignSectionContexts: Record> = {
"mail-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" },
- "global-settings": { id: "campaign.global-settings", title: "Global settings" },
- settings: { id: "campaign.global-settings", title: "Global settings" },
+ "global-settings": { id: "campaign.global-settings", title: "Policies" },
+ settings: { id: "campaign.global-settings", title: "Policies" },
review: { id: "campaign.review-preview", title: "Review & Send workflow preview" },
send: { id: "campaign.send", title: "Review & Send" },
report: { id: "campaign.report", title: "Report" },