feat(campaign): govern attachment reuse

This commit is contained in:
2026-08-20 05:20:07 +02:00
parent f4fe534ee1
commit 112ef9dc31
16 changed files with 705 additions and 2 deletions
@@ -58,6 +58,9 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
const basePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
const globalRules = useMemo(() => normalizeAttachmentRules(attachments.global), [attachments.global]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
const reusePolicy = asRecord(attachments.reuse_policy);
const reuseAction = ["allow", "warn", "review", "block"].includes(String(reusePolicy.action)) ? String(reusePolicy.action) : "allow";
const reuseAllowance = ["same_recipient", "same_message"].includes(String(reusePolicy.allow_within)) ? String(reusePolicy.allow_within) : "none";
const residualFiles = asRecord(attachments.residual_files);
const residualMode = ["report", "attach"].includes(String(residualFiles.mode)) ? String(residualFiles.mode) : "none";
const residualRecipient = asRecord(residualFiles.recipient);
@@ -113,6 +116,16 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
});
}
function patchReusePolicy(next: Record<string, unknown>) {
if (locked) return;
patch(["attachments", "reuse_policy"], {
action: "allow",
allow_within: "none",
...reusePolicy,
...next
});
}
function patchBasePath(index: number, patch: Partial<AttachmentBasePath>) {
patchBasePaths(basePaths.map((basePath, currentIndex) => currentIndex === index ? { ...basePath, ...patch } : basePath));
}
@@ -306,6 +319,29 @@ export default function AttachmentsDataPage({ settings, campaignId }: {settings:
</div>
</Card>
<Card title="Attachment reuse policy" collapsible>
<div className="campaign-residual-file-form">
<FormField label="Duplicate-file action" help="Applied when the same resolved source file appears more than once outside the configured allowance.">
<select value={reuseAction} disabled={locked} onChange={(event) => patchReusePolicy({ action: event.target.value })}>
<option value="allow">Allow and record</option>
<option value="warn">Allow with warning</option>
<option value="review">Require explicit review</option>
<option value="block">Block delivery</option>
</select>
</FormField>
<FormField label="Allowed reuse boundary" help="A boundary is an exception to the selected action. No exception applies the action to every repeated use.">
<select value={reuseAllowance} disabled={locked} onChange={(event) => patchReusePolicy({ allow_within: event.target.value })}>
<option value="none">No exception</option>
<option value="same_recipient">Within the same recipient</option>
<option value="same_message">Within the same built message</option>
</select>
</FormField>
</div>
<DismissibleAlert tone={reuseAction === "block" ? "warning" : "info"} dismissible={false} compact>
Every repeated-file finding is retained in the build protocol. Review requires a reason bound to the exact build; Block prevents affected messages from being queued.
</DismissibleAlert>
</Card>
<Card title="Unassigned file disposition" collapsible>
<div className="campaign-residual-file-form">
<FormField label="Action" help="Only sources with Unsent enabled are inspected. The existing warning policy remains active when no disposition is selected.">
@@ -158,6 +158,9 @@ export default function ReviewSendPage({
);
const validation = asRecord(version?.validation_summary);
const build = asRecord(version?.build_summary);
const attachmentReuse = asRecord(build.attachment_reuse);
const attachmentReusePolicy = asRecord(attachmentReuse.policy);
const attachmentReuseFindings = asArray(attachmentReuse.findings).map(asRecord);
const residualFileDisposition = asRecord(build.residual_file_disposition);
const residualFileRecipient = asRecord(residualFileDisposition.recipient);
const printOutput = asRecord(build.print_output);
@@ -1504,6 +1507,29 @@ export default function ReviewSendPage({
</DescriptionList>
</div>
)}
{hasBuild && Object.keys(attachmentReuse).length > 0 && (
<div className="review-flow-data-section" data-attachment-reuse-policy>
<h3>Attachment reuse policy</h3>
<DescriptionList variant="inline">
<div><dt>Action</dt><dd>{humanize(String(attachmentReusePolicy.action ?? "allow"))}</dd></div>
<div><dt>Allowed boundary</dt><dd>{humanize(String(attachmentReusePolicy.allow_within ?? "none"))}</dd></div>
<div><dt>Repeated files</dt><dd>{String(attachmentReuse.duplicate_file_count ?? 0)}</dd></div>
<div><dt>Allowed</dt><dd>{String(attachmentReuse.allowed_file_count ?? 0)}</dd></div>
<div><dt>Policy findings</dt><dd>{String(attachmentReuse.violation_file_count ?? 0)}</dd></div>
<div><dt>Affected messages</dt><dd>{String(attachmentReuse.affected_message_count ?? 0)}</dd></div>
</DescriptionList>
{attachmentReuseFindings.length > 0 && (
<ul className="small-note">
{attachmentReuseFindings.slice(0, 10).map((finding) => (
<li key={String(finding.file_fingerprint)}>
{String(finding.file_name ?? "Attachment")} · {String(finding.use_count ?? 0)} uses · {humanize(String(finding.disposition ?? "allowed"))}
</li>
))}
</ul>
)}
{attachmentReuseFindings.length > 10 && <p className="muted small-note">Showing 10 of {attachmentReuseFindings.length} repeated files.</p>}
</div>
)}
{getText(printOutput, "render_id") && (
<div className="review-flow-data-section">
<div className="page-heading split">
@@ -28,6 +28,10 @@ export function ensureCampaignDraft(version: CampaignVersionDetail | null): Reco
allow_individual: false,
send_without_attachments: true,
send_without_attachments_behavior: "continue",
reuse_policy: {
action: "allow",
allow_within: "none"
},
global: [],
residual_files: {
mode: "none",
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const guidance = readFileSync(resolve(here, "../src/features/campaigns/review/ReviewWorkflowGuidance.tsx"), "utf8");
const page = readFileSync(resolve(here, "../src/features/campaigns/ReviewSendPage.tsx"), "utf8");
const attachmentsPage = readFileSync(resolve(here, "../src/features/campaigns/AttachmentsDataPage.tsx"), "utf8");
assert.match(guidance, /<ActionBlockerHint/);
assert.match(guidance, /<GuidedReviewList/);
@@ -23,5 +24,15 @@ assert.match(page, /data-residual-file-policy/);
assert.match(page, /build\.residual_file_disposition/);
assert.match(page, /residual_file_count/);
assert.match(page, /watched_source_count/);
assert.match(page, /data-attachment-reuse-policy/);
assert.match(page, /build\.attachment_reuse/);
assert.match(page, /attachmentReusePolicy\.allow_within/);
assert.match(attachmentsPage, /patch\(\["attachments", "reuse_policy"\]/);
for (const action of ["allow", "warn", "review", "block"]) {
assert.match(attachmentsPage, new RegExp(`<option value="${action}">`));
}
for (const allowance of ["none", "same_recipient", "same_message"]) {
assert.match(attachmentsPage, new RegExp(`<option value="${allowance}">`));
}
assert.doesNotMatch(page, /blocked_or_failed_message_s_must_be_resolved_bef/);
assert.doesNotMatch(page, /resolve_the_blocking_entries_then_validate_again/);