feat: harden campaign delivery and editing

This commit is contained in:
2026-07-30 14:27:04 +02:00
parent 4a120e8009
commit cd223cbb95
34 changed files with 3370 additions and 165 deletions
+139 -16
View File
@@ -36,7 +36,7 @@ import {
type CampaignSummary } from
"../../api/campaigns";
import { getMockMailboxMessage, type MockMailboxMessage } from "../../api/mail";
import { Button, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { Button, Dialog, SegmentedControl, hasScope, useDeltaWatermarks, useGuardedNavigate, usePlatformUiCapability, type AuthInfo, type MailDevMailboxUiCapability } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn, type DataGridListOption, type DataGridQueryState } from "@govoplan/core-webui";
import { DismissibleAlert, TableActionGroup } from "@govoplan/core-webui";
import { ConfirmDialog } from "@govoplan/core-webui";
@@ -159,6 +159,8 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const [newlyReviewedRequiredKeys, setNewlyReviewedRequiredKeys] = useState<Set<string>>(() => new Set());
const [selectedBuiltIndex, setSelectedBuiltIndex] = useState<number | null>(null);
const [singleSendConfirmIndex, setSingleSendConfirmIndex] = useState<number | null>(null);
const [singleMessageActionKind, setSingleMessageActionKind] = useState<"test" | "single_send" | "single_resend">("single_send");
const [singleMessageResendReason, setSingleMessageResendReason] = useState("");
const [mockResult, setMockResult] = useState<Record<string, unknown> | null>(null);
const [mockClearFirst, setMockClearFirst] = useState(true);
const [mockAppendSent, setMockAppendSent] = useState(true);
@@ -1103,8 +1105,11 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
setError("");
try {
const response = await sendCampaignJob(settings, campaignId, jobId, {
kind: singleMessageActionKind,
idempotency_key: crypto.randomUUID(),
reason: singleMessageActionKind === "single_resend" ? singleMessageResendReason.trim() : undefined,
context: { surface: "campaign_review_preview" },
include_warnings: true,
dry_run: false,
use_rate_limit: true,
enqueue_imap_task: backgroundWorkersEnabled
});
@@ -1112,18 +1117,20 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
const sendResult = asRecord(actionResult.result);
const status = String(sendResult.status ?? "submitted");
const attemptCount = Number(sendResult.attempt_number ?? row.attempt_count ?? 0);
setBuiltReviewRows((current) => current.map((item) => String(item.id ?? "") === jobId ?
const finalSendStatus = String(actionResult.final_send_status ?? "");
if (singleMessageActionKind !== "test") setBuiltReviewRows((current) => current.map((item) => String(item.id ?? "") === jobId ?
{
...item,
send_status: status === "already_accepted" ? "smtp_accepted" : status,
queue_status: ["smtp_accepted", "already_accepted"].includes(status) ? "draft" : item.queue_status,
send_status: finalSendStatus || (status === "already_accepted" ? "smtp_accepted" : status),
queue_status: ["smtp_accepted", "already_accepted", "accepted", "accepted_with_refusals"].includes(finalSendStatus || status) ? "draft" : item.queue_status,
imap_status: sendResult.imap_status ?? item.imap_status,
attempt_count: Number.isFinite(attemptCount) ? attemptCount : item.attempt_count,
last_error: sendResult.message ?? ""
} :
item));
setMessage(`Single message send finished: ${humanize(status)}.`);
setMessage(`${humanize(singleMessageActionKind)} finished: ${humanize(String(actionResult.status ?? status))}.`);
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
setSelectedBuiltIndex(null);
await refreshQueueStatus(true);
await reload();
@@ -1146,6 +1153,7 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
setNewlyReviewedRequiredKeys(new Set());
setSelectedBuiltIndex(null);
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
setMockResult(null);
setSelectedMockMessage(null);
resetDeltaWatermark();
@@ -1660,7 +1668,18 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
canStartSingleMessageSend={canStartSingleMessageSend}
singleMessageSendBusy={busy === "send"}
onSelect={openBuiltMessageAtIndex}
onSendSingle={(targetIndex) => setSingleSendConfirmIndex(targetIndex)}
onSendSingle={(targetIndex) => {
const target = filteredBuiltReviewRows[targetIndex];
const status = String(target?.send_status ?? "not_queued");
setSingleMessageActionKind(
["not_queued", "cancelled", "queued"].includes(status)
&& Number(target?.attempt_count ?? 0) === 0
? "single_send"
: "single_resend"
);
setSingleMessageResendReason("");
setSingleSendConfirmIndex(targetIndex);
}}
onClose={() => setSelectedBuiltIndex(null)} />
}
@@ -1724,16 +1743,103 @@ export default function ReviewSendPage({ settings, auth, campaignId }: {settings
onCancel={() => setCancelDeliveryConfirmOpen(false)}
onConfirm={() => void runDeliveryControl("cancel")} />
<ConfirmDialog
<Dialog
open={singleSendConfirmRow !== null}
title="Send this one message now?"
message={singleSendConfirmRow ? `This sends only the currently selected built message to ${formatAddressList(asRecord(singleSendConfirmRow.resolved_recipients).to) || String(singleSendConfirmRow.recipient_email ?? "the resolved recipient")}. The send is recorded as a normal SMTP attempt in the delivery protocol.` : ""}
confirmLabel="Send one message"
cancelLabel="i18n:govoplan-campaign.cancel.77dfd213"
tone="danger"
busy={busy === "send"}
onCancel={() => setSingleSendConfirmIndex(null)}
onConfirm={() => void sendSingleBuiltMessage()} />
title="One-message delivery"
portal
closeDisabled={busy === "send"}
onClose={() => {
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
}}
footer={
<>
<Button
disabled={busy === "send"}
onClick={() => {
setSingleSendConfirmIndex(null);
setSingleMessageResendReason("");
}}
>
i18n:govoplan-campaign.cancel.77dfd213
</Button>
<Button
variant="danger"
disabled={
busy === "send"
|| (singleMessageActionKind === "single_resend" && !singleMessageResendReason.trim())
}
onClick={() => void sendSingleBuiltMessage()}
>
{busy === "send" ? "Sending..." : "Confirm action"}
</Button>
</>
}
>
{singleSendConfirmRow && (
<div className="form-grid">
<SegmentedControl
value={singleMessageActionKind}
width="fill"
size="equal"
ariaLabel="One-message action"
options={[
{ id: "test", label: "Test" },
{
id: "single_send",
label: "Initial send",
disabled: !(
["not_queued", "cancelled", "queued"].includes(String(singleSendConfirmRow.send_status ?? "not_queued"))
&& Number(singleSendConfirmRow.attempt_count ?? 0) === 0
)
},
{
id: "single_resend",
label: "Resend",
disabled: ["claimed", "sending", "outcome_unknown"].includes(String(singleSendConfirmRow.send_status ?? ""))
}
]}
onChange={setSingleMessageActionKind}
/>
<p className="muted small-note">
{singleMessageActionKind === "test"
? "Transports this exact rendered message once without changing its official delivery state."
: singleMessageActionKind === "single_resend"
? "Transports this exact message again. A successful resend can complete it; a failed resend preserves an earlier success."
: "Makes the first official delivery attempt and changes only this message after SMTP acceptance."}
</p>
<dl className="detail-grid">
<div>
<dt>Recipient</dt>
<dd>{formatAddressList(asRecord(singleSendConfirmRow.resolved_recipients).to) || String(singleSendConfirmRow.recipient_email ?? "—")}</dd>
</div>
<div>
<dt>Version</dt>
<dd>{String(version?.version_number ?? "—")}</dd>
</div>
<div>
<dt>Message</dt>
<dd><code>{String(singleSendConfirmRow.eml_sha256 ?? "—").slice(0, 16)}</code></dd>
</div>
<div>
<dt>State</dt>
<dd>{humanize(String(singleSendConfirmRow.send_status ?? "not_queued"))}</dd>
</div>
</dl>
{singleMessageActionKind === "single_resend" && (
<label>
Reason
<textarea
value={singleMessageResendReason}
maxLength={2000}
rows={3}
onChange={(event) => setSingleMessageResendReason(event.target.value)}
/>
</label>
)}
</div>
)}
</Dialog>
{selectedDeliveryJobDetail &&
<DeliveryJobDetailOverlay detail={selectedDeliveryJobDetail} onClose={() => setSelectedDeliveryJobDetail(null)} />
@@ -1776,6 +1882,23 @@ reviewedKeys: Set<string>)
list: { options: MESSAGE_VALIDATION_OPTIONS, display: "pill" },
value: (row) => String(row.validation_status ?? "unknown")
},
{
id: "postboxTargets",
header: "Postbox targets",
width: 145,
align: "right",
sortable: true,
filterType: "integer",
value: (row) => Number(row.postbox_target_count ?? 0),
render: (row) => {
const count = Number(row.postbox_target_count ?? 0);
return count > 0 ? (
<StatusBadge status="info" label={`${count} frozen`} />
) : (
<span className="muted"></span>
);
}
},
{ id: "attachments", header: "i18n:govoplan-campaign.attachments.6771ade6", width: 125, sortable: true, filterable: true, filterType: "integer", align: "right", value: (row) => Number(row.attachment_count ?? countResolvedAttachments(row.attachments)) },
{ id: "reviewed", header: "i18n:govoplan-campaign.reviewed.31ef8593", width: 110, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "yes", label: "i18n:govoplan-campaign.reviewed.31ef8593" }, { value: "no", label: "i18n:govoplan-campaign.not_reviewed.0a0e3cff" }] }, render: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? <Check size={17} aria-label="i18n:govoplan-campaign.reviewed.31ef8593" /> : <span className="muted"></span>, value: (row, index) => row.reviewed === true || reviewedKeys.has(String(row.review_key ?? builtMessageKey(row, index))) ? "yes" : "no" },
{ id: "actions", header: "i18n:govoplan-campaign.actions.c3cd636a", width: 72, sticky: "end", render: (row) => <TableActionGroup actions={[{ id: "review", label: "i18n:govoplan-campaign.review.e29a79fe", icon: <Search aria-hidden="true" />, onClick: () => openMessage(row) }]} /> }];