Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 39ad3500e2
commit 23318c709a
98 changed files with 3432 additions and 2339 deletions

View File

@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import type { ApiSettings } from "../../types";
import {
appendSent,
buildVersion,
getCampaignJobs,
getCampaignJobDetail,
@@ -26,16 +27,16 @@ import {
type CampaignVersionDetail,
} from "../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import Button from "../../components/Button";
import { Button, usePlatformUiCapability, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import DataGrid, { type DataGridColumn, type DataGridListOption } from "../../components/table/DataGrid";
import DismissibleAlert from "../../components/DismissibleAlert";
import ConfirmDialog from "../../components/ConfirmDialog";
import LoadingFrame from "../../components/LoadingFrame";
import PageTitle from "../../components/PageTitle";
import StatusBadge from "../../components/StatusBadge";
import ToggleSwitch from "../../components/ToggleSwitch";
import InlineHelp from "../../components/help/InlineHelp";
import MessagePreviewOverlay, { type MessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import { DismissibleAlert } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { PageTitle } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { ToggleSwitch } from "@govoplan/core-webui";
import { InlineHelp } from "@govoplan/core-webui";
import CampaignMessagePreviewOverlay, { type CampaignMessagePreviewAttachment } from "./components/MessagePreviewOverlay";
import LockedVersionNotice from "./components/LockedVersionNotice";
import VersionLine from "./components/VersionLine";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
@@ -77,7 +78,7 @@ type FlowStageDefinition = {
lockReason?: string;
};
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "";
type WorkflowBusy = "validate" | "build" | "inspect" | "mock" | "mailbox" | "send" | "imap" | "";
const stateColors: Record<FlowState, string> = {
complete: "var(--green)",
@@ -103,6 +104,12 @@ const MESSAGE_REVIEW_ISSUE_STATUSES = ["warning", "needs_review", "blocked", "ex
export default function ReviewSendPage({ settings, campaignId }: { settings: ApiSettings; campaignId: string }) {
const navigate = useNavigate();
const devMailboxCapability = usePlatformUiCapability<MailDevMailboxUiCapability>("mail.devMailbox");
const mockWorkflowAvailable = devMailboxCapability?.enabled === true;
const [mockVerificationRequired, setMockVerificationRequired] = useState(true);
const [mockMailboxPreviewEnabled, setMockMailboxPreviewEnabled] = useState(true);
const mockWorkflowRequired = mockWorkflowAvailable && mockVerificationRequired;
const mockMailboxPreviewActive = mockWorkflowAvailable && mockMailboxPreviewEnabled;
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId, { includeSummary: true });
const [liveSummary, setLiveSummary] = useState<CampaignSummary | null>(null);
const [queueStatusLoading, setQueueStatusLoading] = useState(false);
@@ -140,6 +147,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
const [dryRun, setDryRun] = useState(false);
const [sendConfirmOpen, setSendConfirmOpen] = useState(false);
const [sendResult, setSendResult] = useState<Record<string, unknown> | null>(null);
const [imapAppendResult, setImapAppendResult] = useState<Record<string, unknown> | null>(null);
const [imapDiagnostics, setImapDiagnostics] = useState<CampaignJobsResponse>(() => emptyCampaignJobsResponse());
const [selectedDeliveryJobDetail, setSelectedDeliveryJobDetail] = useState<Record<string, unknown> | null>(null);
const persistedReview = storedMessageReviewState(version);
const persistedReviewKey = `${persistedReview.buildToken}|${persistedReview.inspectionComplete ? "1" : "0"}|${persistedReview.reviewedMessageKeys.join(",")}`;
@@ -153,6 +163,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
setMockResult(null);
setSelectedMockMessage(null);
setSendResult(null);
setImapAppendResult(null);
setImapDiagnostics(emptyCampaignJobsResponse());
setSelectedDeliveryJobDetail(null);
setSendConfirmOpen(false);
setLiveSummary(null);
}, [version?.id]);
@@ -162,10 +175,16 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
setReviewedMessageKeys(new Set(persistedReview.reviewedMessageKeys));
}, [version?.id, persistedReviewKey]);
useEffect(() => {
if (!mockMailboxPreviewActive) setSelectedMockMessage(null);
}, [mockMailboxPreviewActive]);
const validationPresent = Object.keys(validation).length > 0;
const validationOk = validation.ok === true;
const validationErrors = numberFrom(validation, ["error_count", "errors", "blocked"]);
const validationWarnings = numberFrom(validation, ["warning_count", "warnings"]);
const validationIssues = asArray(validation.issues).map(asRecord);
const visibleValidationIssues = validationIssues.slice(0, 10);
const readyForDelivery = isVersionReadyForDelivery(version);
const validationStale = validationOk && !readyForDelivery;
@@ -185,6 +204,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
const statusCounts = asRecord(summary?.status_counts);
const sendStatusCounts = asRecord(statusCounts.send);
const imapStatusCounts = asRecord(statusCounts.imap);
const attempts = asRecord(summary?.attempts);
const summaryDelivery = asRecord(summary?.delivery);
const queuedSendCount = numberFrom(sendStatusCounts, ["queued"]);
@@ -204,6 +224,8 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
const failedCount = cards?.failed ?? 0;
const imapAppended = cards?.imap_appended ?? 0;
const imapFailed = cards?.imap_failed ?? 0;
const imapPending = numberFrom(imapStatusCounts, ["pending"]);
const imapSkipped = numberFrom(imapStatusCounts, ["skipped"]);
const deliveryHasTerminalOutcome = sentCount + failedCount + outcomeUnknownCount + cancelledCount > 0;
const currentWorkflowState = (version?.workflow_state ?? "").toLowerCase();
const deliverySending = activeSendCount > 0 || (currentWorkflowState === "sending" && queuedOrActiveCount > 0);
@@ -294,6 +316,13 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
const mockMailbox = asRecord(mockResult?.mailbox);
const mockMailboxMessages = asArray(mockMailbox.messages).map(asRecord);
const sendResultRows = asArray(sendResult?.results).map(asRecord);
const imapAppendResultRows = asArray(imapAppendResult?.results).map(asRecord);
const imapDiagnosticRows = imapDiagnostics.jobs.map(asRecord);
const imapDiagnosticsPending = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "pending").length;
const imapDiagnosticsFailed = imapDiagnosticRows.filter((job) => String(job.imap_status ?? "").toLowerCase() === "failed").length;
const imapPendingForDisplay = Math.max(imapPending, imapDiagnosticsPending);
const imapFailedForDisplay = Math.max(imapFailed, imapDiagnosticsFailed);
const canAppendPendingImap = Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && !historicalVersion && !userLockedVersion;
const validationReviewState: FlowState = busy === "validate"
? "running"
@@ -322,21 +351,29 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
? "complete"
: "active";
const mockState: FlowState = !inspectionSatisfied
const mockState: FlowState = !mockWorkflowAvailable
? "locked"
: busy === "mock" || busy === "mailbox"
? "running"
: mockPartial
? "partial"
: mockFailed > 0
? "danger"
: mockComplete
? "complete"
: downstreamDeliveryActivity
? "warning"
: "active";
: !inspectionSatisfied
? "locked"
: busy === "mock" || busy === "mailbox"
? "running"
: mockPartial
? "partial"
: mockFailed > 0
? "danger"
: mockComplete
? "complete"
: downstreamDeliveryActivity
? "warning"
: "active";
const mockGateSatisfied = mockComplete || downstreamDeliveryActivity;
const mockStateDisplayLabel = !mockWorkflowAvailable ? "Unavailable" : !mockVerificationRequired ? "Optional" : stateLabel(mockState);
const mockGateSatisfied = inspectionSatisfied && (!mockWorkflowRequired || mockComplete || downstreamDeliveryActivity);
const sendLockReason = !inspectionSatisfied
? "Build and complete the required message review first."
: mockWorkflowRequired
? "Complete a successful mock delivery first."
: "Build the exact queue first.";
const sendState: FlowState = !mockGateSatisfied
? "locked"
: busy === "send" || deliverySending
@@ -391,11 +428,11 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
id: "workflow-mock-verify",
title: "Mock send and verify",
shortTitle: "Mock send",
description: "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers.",
description: "Exercise the delivery path and verify recipient outcomes and captured MIME messages without contacting the real servers. This dev workflow can be optional before real sending.",
icon: FlaskConical,
state: mockState,
stateLabel: stateLabel(mockState),
lockReason: "Build and complete the required message review first.",
stateLabel: mockStateDisplayLabel,
lockReason: mockWorkflowAvailable ? "Build and complete the required message review first." : "Enable the Mail dev mailbox capability to run mock delivery.",
},
{
id: "workflow-send",
@@ -405,7 +442,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
icon: Send,
state: sendState,
stateLabel: stateLabel(sendState),
lockReason: "Complete a successful mock delivery first.",
lockReason: sendLockReason,
},
{
id: "workflow-results",
@@ -424,6 +461,9 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
sendState,
resultState,
validationErrors,
mockStateDisplayLabel,
mockWorkflowAvailable,
sendLockReason,
]);
async function runValidation() {
@@ -502,7 +542,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
}
async function runMockSend() {
if (!version || busy || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
if (!version || busy || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted) return;
setBusy("mock");
setMessage("Running the complete mock-delivery flow…");
setError("");
@@ -571,6 +611,73 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
}
}
async function runAppendSent() {
if (!version || busy || !canAppendPendingImap) return;
const runInline = !backgroundWorkersEnabled;
setBusy("imap");
setError("");
setMessage(runInline ? "Appending pending Sent copies via IMAP..." : "Queueing pending IMAP append jobs...");
try {
const response = await appendSent(settings, campaignId, {
enqueue_celery: backgroundWorkersEnabled,
run_inline: runInline,
dry_run: false,
});
const result = asRecord(response.result ?? response);
setImapAppendResult(result);
const appended = numberFrom(result, ["appended_count"]);
const failed = numberFrom(result, ["failed_count"]);
const enqueued = numberFrom(result, ["enqueued_count"]);
const pending = numberFrom(result, ["pending_count"]);
setMessage(runInline
? `IMAP append processed ${pending} pending job(s): appended ${appended}, failed ${failed}.`
: `Queued ${enqueued} pending IMAP append job(s).`);
await refreshQueueStatus(true);
await loadImapDiagnostics(true);
} catch (err) {
setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function loadImapDiagnostics(silent = false) {
if (!version?.id) return;
setBusy("inspect");
if (!silent) setMessage("Loading IMAP diagnostics...");
setError("");
try {
const result = await getCampaignJobs(settings, campaignId, {
versionId: version.id,
page: 1,
pageSize: 50,
imapStatus: ["pending", "failed"],
});
setImapDiagnostics(result);
if (!silent) setMessage(`Loaded ${result.total} pending/failed IMAP job(s).`);
} catch (err) {
if (!silent) setMessage("");
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function openDeliveryJobDetail(jobId: string) {
if (!jobId || busy) return;
setBusy("inspect");
setError("");
try {
const detail = await getCampaignJobDetail(settings, campaignId, jobId);
setSelectedDeliveryJobDetail(detail as unknown as Record<string, unknown>);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy("");
}
}
async function completeInspection(_acceptBulk = false) {
if (!version || busy || readOnlyVersion || automaticInspectionComplete || !canCompleteInspection || downstreamDeliveryActivity) return;
setBusy("inspect");
@@ -605,7 +712,7 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
}
async function openMockMessage(id: string) {
if (!id || busy === "mailbox") return;
if (!id || busy === "mailbox" || !mockMailboxPreviewActive) return;
setBusy("mailbox");
setError("");
try {
@@ -724,6 +831,24 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
{validationErrors > 0 && (
<p className="review-flow-inline-note is-danger">Resolve the blocking entries, then validate again.</p>
)}
{visibleValidationIssues.length > 0 && (
<div className="review-flow-data-section">
<h3>Validation details</h3>
<dl className="detail-list">
{visibleValidationIssues.map((issue, index) => (
<div key={`${String(issue.code ?? "issue")}:${index}`}>
<dt>{humanize(String(issue.severity ?? "issue"))}</dt>
<dd>
<strong>{String(issue.message ?? issue.code ?? "Validation issue")}</strong>
{issue.path ? <span className="muted"> · {String(issue.path)}</span> : null}
{issue.code ? <span className="muted"> · {String(issue.code)}</span> : null}
</dd>
</div>
))}
</dl>
{validationIssues.length > visibleValidationIssues.length && <p className="muted small-note">Showing {visibleValidationIssues.length} of {validationIssues.length} validation issue(s).</p>}
</div>
)}
<div className="button-row compact-actions review-flow-stage-actions">
<Button
variant="primary"
@@ -808,17 +933,22 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
<WorkflowFact label="Captured SMTP" value={mockResult ? mockSent : "—"} />
<WorkflowFact label="Mock failures" value={mockResult ? mockFailed : "—"} />
<WorkflowFact label="Skipped" value={mockResult ? mockSkipped : "—"} />
<WorkflowFact label="Captured messages" value={mockResult ? mockMailboxMessages.length : "—"} />
<WorkflowFact label="Captured messages" value={!mockWorkflowAvailable ? "Unavailable" : mockResult ? mockMailboxMessages.length : "—"} />
</div>
<div className="button-row compact-actions review-flow-stage-actions">
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
<Button variant="primary" onClick={() => void runMockSend()} disabled={!version || Boolean(busy) || !mockWorkflowAvailable || readOnlyVersion || !inspectionSatisfied || deliveryQueued || deliveryStarted}>
{busy === "mock" ? "Running mock delivery…" : mockResult ? "Run mock delivery again" : "Run mock delivery"}
</Button>
<Button onClick={() => navigate("../mail-settings")}>Review server settings</Button>
</div>
{!mockWorkflowAvailable && (
<p className="review-flow-inline-note is-stale">Mock delivery uses the Mail module development mailbox API. Enable that capability to run and inspect captured messages.</p>
)}
<div className="toggle-row mock-send-options">
<ToggleSwitch label="Clear mock mailbox first" checked={mockClearFirst} disabled={Boolean(busy)} onChange={setMockClearFirst} />
<ToggleSwitch label="Append mock Sent copy" checked={mockAppendSent} disabled={Boolean(busy)} onChange={setMockAppendSent} />
<ToggleSwitch label="Require mock before real send" checked={mockVerificationRequired} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockVerificationRequired} />
<ToggleSwitch label="Show captured mock mailbox" checked={mockMailboxPreviewEnabled && mockWorkflowAvailable} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockMailboxPreviewEnabled} />
<ToggleSwitch label="Clear mock mailbox first" checked={mockClearFirst} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockClearFirst} />
<ToggleSwitch label="Append mock Sent copy" checked={mockAppendSent} disabled={!mockWorkflowAvailable || Boolean(busy)} onChange={setMockAppendSent} />
</div>
{mockResult && (
<div className="review-flow-data-stack">
@@ -833,17 +963,24 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
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>
{mockMailboxPreviewActive ? (
<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>
) : (
<section className="review-flow-data-section" aria-labelledby="workflow-mock-mailbox-title">
<h3 id="workflow-mock-mailbox-title">Captured mock messages</h3>
<p className="muted small-note">{mockWorkflowAvailable ? "Captured mailbox preview is disabled for this run. Recipient outcomes remain available." : "Captured mailbox preview requires the Mail development mailbox API."}</p>
</section>
)}
</div>
)}
</WorkflowStage>
@@ -916,12 +1053,59 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
<WorkflowFact label="Queued / active" value={queuedOrActiveCount} />
<WorkflowFact label="Outcome unknown" value={outcomeUnknownCount} />
<WorkflowFact label="IMAP appended" value={imapAppended} />
<WorkflowFact label="IMAP failed" value={imapFailed} />
<WorkflowFact label="IMAP pending" value={imapPendingForDisplay} />
<WorkflowFact label="IMAP failed" value={imapFailedForDisplay} />
<WorkflowFact label="IMAP skipped" value={imapSkipped} />
</div>
<div className="review-flow-result-line">
<StatusBadge status={deliveryDisplayStatus} />
<span>{deliveryStarted ? "Delivery activity is available in the report and audit views." : "No real delivery has started for this campaign version."}</span>
</div>
{Boolean(imapAppend.enabled) && imapPendingForDisplay > 0 && (
<p className="review-flow-inline-note is-stale">IMAP Sent append is still pending for {imapPendingForDisplay} job(s). Pending with no IMAP attempt usually means the append worker was not queued or is not running.</p>
)}
{Boolean(imapAppend.enabled) && imapFailedForDisplay > 0 && (
<p className="review-flow-inline-note is-danger">{imapFailedForDisplay} IMAP append job(s) failed. Load diagnostics to inspect the last error per job.</p>
)}
{Boolean(imapAppend.enabled) && (imapPendingForDisplay > 0 || imapFailedForDisplay > 0) && (
<div className="button-row compact-actions review-flow-stage-actions">
<Button onClick={() => void loadImapDiagnostics(false)} disabled={Boolean(busy)}>Load IMAP diagnostics</Button>
{imapPendingForDisplay > 0 && (
<Button variant="primary" onClick={() => void runAppendSent()} disabled={Boolean(busy) || !canAppendPendingImap}>
{busy === "imap" ? "Appending IMAP..." : backgroundWorkersEnabled ? "Queue pending IMAP append" : "Append pending IMAP now"}
</Button>
)}
</div>
)}
{imapAppendResult && (
<div className="review-flow-data-section">
<h3>IMAP append result</h3>
<p className="muted small-note">Pending {String(imapAppendResult.pending_count ?? "0")}, enqueued {String(imapAppendResult.enqueued_count ?? "0")}, processed {String(imapAppendResult.processed_count ?? "0")}, appended {String(imapAppendResult.appended_count ?? "0")}, failed {String(imapAppendResult.failed_count ?? "0")}.</p>
{imapAppendResultRows.length > 0 && (
<DataGrid
id={`campaign-${campaignId}-workflow-imap-append-results`}
rows={imapAppendResultRows}
columns={imapAppendResultColumns()}
getRowKey={(row, index) => String(row.job_id ?? index)}
emptyText="No IMAP append result rows returned."
className="data-table-wrap data-table compact-table"
/>
)}
</div>
)}
{imapDiagnostics.total > 0 && (
<div className="review-flow-data-section">
<h3>IMAP diagnostics</h3>
<DataGrid
id={`campaign-${campaignId}-workflow-imap-diagnostics`}
rows={imapDiagnosticRows}
columns={imapDiagnosticColumns(openDeliveryJobDetail)}
getRowKey={(row, index) => String(row.id ?? index)}
emptyText="No pending or failed IMAP jobs found."
className="data-table-wrap data-table compact-table"
/>
</div>
)}
{recentFailures.length > 0 && (
<div className="review-flow-data-section">
<h3>Recent delivery failures</h3>
@@ -979,8 +1163,12 @@ export default function ReviewSendPage({ settings, campaignId }: { settings: Api
onConfirm={() => void runSendNow()}
/>
{selectedMockMessage && (
<MessagePreviewOverlay
{selectedDeliveryJobDetail && (
<DeliveryJobDetailOverlay detail={selectedDeliveryJobDetail} onClose={() => setSelectedDeliveryJobDetail(null)} />
)}
{selectedMockMessage && mockMailboxPreviewActive && (
<CampaignMessagePreviewOverlay
title="Captured mock mail"
subject={selectedMockMessage.subject || "Mock message"}
bodyMode="text"
@@ -1134,7 +1322,7 @@ function BuiltMessagePreview({
const resolvedRecipients = asRecord(row.resolved_recipients);
return (
<MessagePreviewOverlay
<CampaignMessagePreviewOverlay
title="Built message review"
subject={subject}
bodyMode={html.trim() ? "html" : "text"}
@@ -1157,6 +1345,59 @@ function BuiltMessagePreview({
);
}
function DeliveryJobDetailOverlay({ detail, onClose }: { detail: Record<string, unknown>; onClose: () => void }) {
const job = asRecord(detail.job);
const attempts = asRecord(detail.attempts);
const smtpAttempts = asArray(attempts.smtp).map(asRecord);
const imapAttempts = asArray(attempts.imap).map(asRecord);
const issues = asArray(job.issues).map(asRecord);
return (
<div className="overlay-backdrop" role="dialog" aria-modal="true" aria-labelledby="delivery-job-detail-title">
<div className="modal-panel template-preview-modal message-preview-modal">
<header className="modal-header">
<h2 id="delivery-job-detail-title">Delivery job details</h2>
<button className="modal-close" onClick={onClose}>×</button>
</header>
<div className="modal-body">
<dl className="detail-list">
<div><dt>Recipient</dt><dd>{formatAddressList(asRecord(job.resolved_recipients).to) || String(job.recipient_email ?? "-")}</dd></div>
<div><dt>Subject</dt><dd>{String(job.subject ?? "-")}</dd></div>
<div><dt>SMTP status</dt><dd>{humanize(String(job.send_status ?? "-"))}</dd></div>
<div><dt>IMAP status</dt><dd>{humanize(String(job.imap_status ?? "-"))}</dd></div>
{job.last_error ? <div><dt>Last error</dt><dd>{String(job.last_error)}</dd></div> : null}
</dl>
{issues.length > 0 && <AttemptList title="Message issues" rows={issues} />}
<AttemptList title="SMTP attempts" rows={smtpAttempts} emptyText="No SMTP attempts were recorded." />
<AttemptList title="IMAP attempts" rows={imapAttempts} emptyText="No IMAP append attempts were recorded. If status is pending, append has not run yet." />
</div>
<footer className="modal-footer"><Button variant="primary" onClick={onClose}>Close</Button></footer>
</div>
</div>
);
}
function AttemptList({ title, rows, emptyText = "No rows." }: { title: string; rows: Record<string, unknown>[]; emptyText?: string }) {
return (
<section className="review-flow-data-section">
<h3>{title}</h3>
{rows.length === 0 ? <p className="muted small-note">{emptyText}</p> : (
<dl className="detail-list">
{rows.map((row, index) => (
<div key={`${String(row.id ?? row.code ?? index)}:${index}`}>
<dt>{String(row.status ?? row.severity ?? row.code ?? `#${index + 1}`)}</dt>
<dd>
<strong>{String(row.message ?? row.error_message ?? row.smtp_response ?? row.folder ?? row.path ?? "-")}</strong>
{row.started_at || row.created_at ? <span className="muted"> · {formatDateTime(String(row.started_at ?? row.created_at))}</span> : null}
{row.finished_at || row.updated_at ? <span className="muted"> {formatDateTime(String(row.finished_at ?? row.updated_at))}</span> : null}
</dd>
</div>
))}
</dl>
)}
</section>
);
}
function WorkflowFact({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="review-flow-fact">
@@ -1224,6 +1465,26 @@ function sendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
];
}
function imapAppendResultColumns(): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "status", header: "Status", width: 150, sticky: "start", sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.status ?? "info")} />, value: (row) => String(row.status ?? "info") },
{ id: "job", header: "Job", width: 180, sortable: true, filterable: true, value: (row) => String(row.job_id ?? "-") },
{ id: "folder", header: "Folder", width: 190, sortable: true, filterable: true, value: (row) => String(row.folder ?? "-") },
{ id: "message", header: "Message", width: "minmax(300px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.message ?? stringifyPreview(row, 180)) },
];
}
function imapDiagnosticColumns(openDetail: (jobId: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
return [
{ id: "recipient", header: "Recipient", width: 240, sticky: "start", resizable: true, sortable: true, filterable: true, value: (row) => formatAddressList(asRecord(row.resolved_recipients).to) || String(row.recipient_email ?? "-") },
{ id: "subject", header: "Subject", width: "minmax(260px, 1fr)", resizable: true, sortable: true, filterable: true, value: (row) => String(row.subject ?? "-") },
{ id: "send", header: "SMTP", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.send_status ?? "info")} />, value: (row) => String(row.send_status ?? "-") },
{ id: "imap", header: "IMAP", width: 150, sortable: true, filterable: true, render: (row) => <StatusBadge status={String(row.imap_status ?? "info")} />, value: (row) => String(row.imap_status ?? "-") },
{ id: "error", header: "Last error", width: "minmax(260px, 1fr)", resizable: true, filterable: true, value: (row) => String(row.last_error ?? "-") },
{ id: "actions", header: "Actions", width: 110, sticky: "end", render: (row) => <Button onClick={() => void openDetail(String(row.id ?? ""))}>Details</Button> },
];
}
function mockMailboxColumns(openMessage: (id: string) => Promise<void>): DataGridColumn<Record<string, unknown>>[] {
const options: DataGridListOption[] = [
{ value: "smtp", label: "SMTP" },
@@ -1243,16 +1504,17 @@ function builtMessageMetaItems(row: Record<string, unknown>) {
return [
{ label: "From", value: formatSingleAddress(recipients.from) || "—" },
{ label: "To", value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—") },
{ label: "CC", value: formatAddressList(recipients.cc) || "—" },
{ label: "BCC", value: formatAddressList(recipients.bcc) || "—" },
{ label: "CC", value: formatAddressList(recipients.cc) || null },
{ label: "BCC", value: formatAddressList(recipients.bcc) || null },
{ 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[] {
function builtMessageAttachments(row: Record<string, unknown>): CampaignMessagePreviewAttachment[] {
return asArray(row.attachments).flatMap((value, index) => {
const attachment = asRecord(value);
const zipProtection = zipProtectionFromBuiltAttachment(attachment);
const managedMatches = asArray(attachment.managed_matches).map(asRecord);
if (managedMatches.length > 0) {
return managedMatches.map((match, matchIndex) => ({
@@ -1262,6 +1524,8 @@ function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAt
sizeBytes: numberOrUndefined(match.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note,
}));
}
const matches = asArray(attachment.matches).filter((match): match is string => typeof match === "string" && Boolean(match.trim()));
@@ -1273,6 +1537,8 @@ function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAt
sizeBytes: numberOrUndefined(attachment.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note,
}));
}
return [{
@@ -1282,14 +1548,36 @@ function builtMessageAttachments(row: Record<string, unknown>): MessagePreviewAt
sizeBytes: numberOrUndefined(attachment.size_bytes),
archiveGroup: stringOrUndefined(attachment.zip_filename),
archiveLabel: stringOrUndefined(attachment.zip_filename),
protected: zipProtection.protected,
protectionNote: zipProtection.note,
}];
});
}
function zipProtectionFromBuiltAttachment(attachment: Record<string, unknown>): { protected: boolean; note: string | null } {
const zipFilename = stringOrUndefined(attachment.zip_filename);
if (!zipFilename) return { protected: false, note: null };
const legacyMode = String(attachment.password_mode ?? attachment.zip_password_mode ?? "").trim();
const protectedArchive = getBool(attachment, "password_enabled", getBool(attachment, "zip_password_protected", getBool(attachment, "zip_protected", ["direct", "field", "template"].includes(legacyMode))));
if (!protectedArchive) return { protected: false, note: null };
const field = stringOrUndefined(attachment.password_field) ?? stringOrUndefined(attachment.zip_password_field);
const rawScope = String(attachment.password_scope ?? attachment.zip_password_scope ?? "local");
const scope = rawScope === "global" ? "global" : "local";
const method = String(attachment.method ?? attachment.zip_method ?? "aes") === "zip_standard" ? "ZipCrypto" : "AES";
const source = field ? `${humanizeScope(scope)} field "${field}"` : "";
return { protected: true, note: [source, `Encryption: ${method}`].filter(Boolean).join(" · ") };
}
function humanizeScope(scope: string): string {
return scope === "global" ? "Global" : "Local";
}
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: "Cc", value: message.cc_header || null },
{ label: "Bcc", value: message.bcc_header || null },
{ label: "Kind", value: message.kind || "—" },
{ label: "Folder", value: message.folder || "—" },
{ label: "Message-ID", value: message.message_id || "—" },
@@ -1297,7 +1585,7 @@ function mockMessageMetaItems(message: MockMailboxMessage) {
];
}
function mockMessageAttachments(message: MockMailboxMessage): MessagePreviewAttachment[] {
function mockMessageAttachments(message: MockMailboxMessage): CampaignMessagePreviewAttachment[] {
return (message.attachments ?? []).map((attachment, index) => ({
filename: attachment.filename || `Attachment ${index + 1}`,
contentType: attachment.content_type || undefined,