Release v0.1.2
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user