373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
import {
|
|
Button,
|
|
DismissibleAlert,
|
|
FormField,
|
|
i18nMessage
|
|
} from "@govoplan/core-webui";
|
|
import { useState } from "react";
|
|
|
|
import CampaignMessagePreviewOverlay, {
|
|
type CampaignMessagePreviewAttachment
|
|
} from "../components/MessagePreviewOverlay";
|
|
import { asArray, asRecord, humanize } from "../utils/campaignView";
|
|
import { getBool, getText } from "../utils/draftEditor";
|
|
import {
|
|
buildTemplatePreviewContext,
|
|
renderTemplatePreviewText
|
|
} from "../utils/templatePlaceholders";
|
|
import {
|
|
formatAddressList,
|
|
formatSingleAddress,
|
|
numberFrom,
|
|
numberOrUndefined,
|
|
stringOrUndefined
|
|
} from "./reviewFormatters";
|
|
import {
|
|
messageNeedsExplicitReview,
|
|
messageRequiresAttachmentOverrideReason
|
|
} from "./builtMessageQuery";
|
|
|
|
export default function BuiltMessagePreview({
|
|
campaignJson,
|
|
entries,
|
|
rows,
|
|
index,
|
|
canStartSingleMessageSend,
|
|
singleMessageSendBusy,
|
|
reviewed,
|
|
reviewReason,
|
|
reviewSaving = false,
|
|
reviewDisabled = false,
|
|
reviewError = "",
|
|
onReviewReasonChange,
|
|
onAcceptReview,
|
|
onSelect,
|
|
onSendSingle,
|
|
onClose
|
|
}: {
|
|
campaignJson: Record<string, unknown>;
|
|
entries: Record<string, unknown>[];
|
|
rows: Record<string, unknown>[];
|
|
index: number;
|
|
canStartSingleMessageSend: boolean;
|
|
singleMessageSendBusy: boolean;
|
|
reviewed: boolean;
|
|
reviewReason: string;
|
|
reviewSaving?: boolean;
|
|
reviewDisabled?: boolean;
|
|
reviewError?: string;
|
|
onReviewReasonChange: (value: string) => void;
|
|
onAcceptReview: (reasonRequired: boolean, reason: string) => Promise<void>;
|
|
onSelect: (index: number) => void;
|
|
onSendSingle: (index: number) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const row = rows[index] ?? {};
|
|
const entryIndex = Math.max(0, numberFrom(row, ["entry_index"]) - 1);
|
|
const entry = entries[entryIndex] ?? {};
|
|
const template = asRecord(campaignJson.template);
|
|
const context = buildTemplatePreviewContext(campaignJson, entry);
|
|
const ignoreEmptyFields = getBool(
|
|
asRecord(campaignJson.validation_policy),
|
|
"ignore_empty_fields",
|
|
false
|
|
);
|
|
const html = renderTemplatePreviewText(
|
|
getText(template, "html"),
|
|
context,
|
|
ignoreEmptyFields
|
|
);
|
|
const text = renderTemplatePreviewText(
|
|
getText(template, "text"),
|
|
context,
|
|
ignoreEmptyFields
|
|
);
|
|
const subject = String(
|
|
row.subject ||
|
|
renderTemplatePreviewText(
|
|
getText(template, "subject"),
|
|
context,
|
|
ignoreEmptyFields
|
|
) ||
|
|
"i18n:govoplan-campaign.no_subject.7b4e8035"
|
|
);
|
|
const issues = asArray(row.issues).map(asRecord);
|
|
const resolvedRecipients = asRecord(row.resolved_recipients);
|
|
const singleSendDisabledReason = singleMessageSendDisabledReason(
|
|
row,
|
|
canStartSingleMessageSend
|
|
);
|
|
const explicitReview = messageNeedsExplicitReview(row);
|
|
const reasonRequired = messageRequiresAttachmentOverrideReason(row);
|
|
|
|
return (
|
|
<CampaignMessagePreviewOverlay
|
|
title="i18n:govoplan-campaign.built_message_review.c7a594f9"
|
|
subject={subject}
|
|
bodyMode={html.trim() ? "html" : "text"}
|
|
text={text}
|
|
html={html}
|
|
recipientLabel={
|
|
formatAddressList(resolvedRecipients.to) ||
|
|
String(row.recipient_email ?? `Message ${index + 1}`)
|
|
}
|
|
recipientNote={
|
|
issues.length > 0
|
|
? `${issues.length} issue${issues.length === 1 ? "" : "s"}: ${issues
|
|
.map((issue) =>
|
|
String(
|
|
issue.message ??
|
|
issue.code ??
|
|
"i18n:govoplan-campaign.issue.73781a12"
|
|
)
|
|
)
|
|
.join(" · ")}`
|
|
: "i18n:govoplan-campaign.built_without_reported_issues.99c1f1a6"
|
|
}
|
|
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)
|
|
}}
|
|
actions={
|
|
<div className="built-message-review-actions">
|
|
{reviewError && <DismissibleAlert tone="danger" dismissible={false}>{reviewError}</DismissibleAlert>}
|
|
{explicitReview && !reviewed ? (
|
|
<ReviewDecisionForm key={String(row.id ?? row.review_key ?? index)} initialReason={reviewReason}
|
|
reasonRequired={reasonRequired} saving={reviewSaving} disabled={reviewDisabled}
|
|
onReasonChange={onReviewReasonChange} onAccept={(reason) => onAcceptReview(reasonRequired, reason)} />
|
|
) : null}
|
|
{reviewed && <p role="status">i18n:govoplan-campaign.review_decision_saved</p>}
|
|
<Button
|
|
variant={explicitReview && !reviewed ? undefined : "primary"}
|
|
disabled={reviewSaving || singleMessageSendBusy || Boolean(singleSendDisabledReason)}
|
|
title={singleSendDisabledReason || "Test, send, or resend only this built message"}
|
|
onClick={() => onSendSingle(index)}
|
|
>
|
|
{singleMessageSendBusy ? "Sending..." : "Message actions..."}
|
|
</Button>
|
|
</div>
|
|
}
|
|
closeDisabled={reviewSaving}
|
|
onClose={onClose}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Keystrokes rerender only this small form, not the campaign table, template
|
|
// rendering and message body. The parent retains a draft ref for navigation.
|
|
function ReviewDecisionForm({ initialReason, reasonRequired, saving, disabled, onReasonChange, onAccept }: {
|
|
initialReason: string; reasonRequired: boolean; saving: boolean; disabled: boolean;
|
|
onReasonChange: (value: string) => void; onAccept: (reason: string) => Promise<void>;
|
|
}) {
|
|
const [reason, setReason] = useState(initialReason);
|
|
return <>
|
|
<FormField label={reasonRequired ? "i18n:govoplan-campaign.review_reason_label" : "i18n:govoplan-campaign.review_note_label"}
|
|
help="i18n:govoplan-campaign.review_reason_help">
|
|
<input value={reason} maxLength={4000} disabled={saving || disabled} onChange={event => {
|
|
setReason(event.target.value); onReasonChange(event.target.value);
|
|
}} />
|
|
</FormField>
|
|
<Button variant="primary" disabled={saving || disabled || reasonRequired && !reason.trim()} onClick={() => void onAccept(reason)}>
|
|
{saving ? "i18n:govoplan-campaign.review_saving_decision" : "i18n:govoplan-campaign.review_accept_next"}
|
|
</Button>
|
|
</>;
|
|
}
|
|
|
|
function singleMessageSendDisabledReason(
|
|
row: Record<string, unknown>,
|
|
canStartSingleMessageSend: boolean
|
|
): string {
|
|
if (!canStartSingleMessageSend) {
|
|
return "Validate, build, and complete the required review/mock gate before sending individual messages.";
|
|
}
|
|
const jobId = String(row.id ?? "");
|
|
if (!jobId) return "This preview row has no delivery job id.";
|
|
const buildStatus = String(row.build_status ?? "");
|
|
if (buildStatus !== "built") return "This message has not been built.";
|
|
const validationStatus = String(row.validation_status ?? "");
|
|
if (["blocked", "excluded", "inactive"].includes(validationStatus)) {
|
|
return `This message is ${humanize(validationStatus)}.`;
|
|
}
|
|
const sendStatus = String(row.send_status ?? "not_queued");
|
|
if (["claimed", "sending", "outcome_unknown"].includes(sendStatus)) {
|
|
return `This message is in delivery state ${humanize(sendStatus)}.`;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function builtMessageMetaItems(row: Record<string, unknown>) {
|
|
const recipients = asRecord(row.resolved_recipients);
|
|
const postboxTargets = asArray(row.resolved_postbox_targets).map(asRecord);
|
|
return [
|
|
{
|
|
label: "i18n:govoplan-campaign.from.3f66052a",
|
|
value: formatSingleAddress(recipients.from) || "—"
|
|
},
|
|
{
|
|
label: "i18n:govoplan-campaign.to.ae79ea1e",
|
|
value: formatAddressList(recipients.to) || String(row.recipient_email ?? "—")
|
|
},
|
|
{
|
|
label: "i18n:govoplan-campaign.cc.c5a976de",
|
|
value: formatAddressList(recipients.cc) || null
|
|
},
|
|
{
|
|
label: "i18n:govoplan-campaign.bcc.4c0145a3",
|
|
value: formatAddressList(recipients.bcc) || null
|
|
},
|
|
{
|
|
label: "i18n:govoplan-campaign.validation.dd74d182",
|
|
value: String(row.validation_status ?? "—")
|
|
},
|
|
{
|
|
label: "Frozen Postbox targets",
|
|
value: postboxTargets.length
|
|
? postboxTargets
|
|
.map((target) => {
|
|
const label = String(
|
|
target.name ?? target.address ?? target.postbox_id ?? "Postbox"
|
|
);
|
|
const address = String(target.address ?? "");
|
|
const context = target.context_key
|
|
? ` [${String(target.context_key)}]`
|
|
: "";
|
|
return `${label}${address && address !== label ? ` <${address}>` : ""}${context}`;
|
|
})
|
|
.join(", ")
|
|
: null
|
|
},
|
|
{
|
|
label: "i18n:govoplan-campaign.mime_size.c8b9d519",
|
|
value: row.eml_size_bytes ? `${String(row.eml_size_bytes)} bytes` : "—"
|
|
}
|
|
];
|
|
}
|
|
|
|
function builtMessageAttachments(
|
|
row: Record<string, unknown>
|
|
): CampaignMessagePreviewAttachment[] {
|
|
return asArray(row.attachments).flatMap<CampaignMessagePreviewAttachment>((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) => ({
|
|
filename: String(
|
|
match.filename ??
|
|
match.display_path ??
|
|
`Attachment ${index + 1}.${matchIndex + 1}`
|
|
),
|
|
detail: String(
|
|
match.display_path ?? match.relative_path ?? attachment.label ?? ""
|
|
),
|
|
contentType: stringOrUndefined(match.content_type),
|
|
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())
|
|
);
|
|
if (matches.length > 0) {
|
|
return matches.map((match) => ({
|
|
filename:
|
|
match.split(/[\\/]/).pop() ||
|
|
String(attachment.label ?? `Attachment ${index + 1}`),
|
|
detail: match,
|
|
contentType: stringOrUndefined(attachment.content_type),
|
|
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
|
archiveGroup: stringOrUndefined(attachment.zip_filename),
|
|
archiveLabel: stringOrUndefined(attachment.zip_filename),
|
|
protected: zipProtection.protected,
|
|
protectionNote: zipProtection.note
|
|
}));
|
|
}
|
|
return [{
|
|
filename: String(
|
|
attachment.filename ??
|
|
attachment.filename_used ??
|
|
attachment.display_path ??
|
|
attachment.label ??
|
|
`Attachment ${index + 1}`
|
|
),
|
|
detail: String(
|
|
attachment.display_path ??
|
|
attachment.source_path ??
|
|
attachment.file_filter ??
|
|
""
|
|
),
|
|
contentType: stringOrUndefined(attachment.content_type),
|
|
sizeBytes: numberOrUndefined(attachment.size_bytes),
|
|
archiveGroup: null,
|
|
archiveLabel: null,
|
|
protected: false,
|
|
protectionNote: null
|
|
}];
|
|
});
|
|
}
|
|
|
|
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"
|
|
? "i18n:govoplan-campaign.zipcrypto.03bf7fb4"
|
|
: "i18n:govoplan-campaign.aes.41f215a6";
|
|
const source = field
|
|
? i18nMessage("i18n:govoplan-campaign.scope_field_value", {
|
|
value0: humanizeScope(scope),
|
|
value1: field
|
|
})
|
|
: "";
|
|
return {
|
|
protected: true,
|
|
note: source
|
|
? i18nMessage("i18n:govoplan-campaign.value_encryption_value", {
|
|
value0: source,
|
|
value1: method
|
|
})
|
|
: i18nMessage("i18n:govoplan-campaign.encryption_value", { value0: method })
|
|
};
|
|
}
|
|
|
|
function humanizeScope(scope: string): string {
|
|
return scope === "global"
|
|
? "i18n:govoplan-campaign.global.5f1184f7"
|
|
: "i18n:govoplan-campaign.local.dc99d54d";
|
|
}
|