feat: harden campaign delivery and editing
This commit is contained in:
@@ -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) }]} /> }];
|
||||
|
||||
Reference in New Issue
Block a user