diff --git a/webui/package.json b/webui/package.json
index b624671..033a756 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -27,6 +27,7 @@
"test:policy-ui": "rm -rf .policy-test-build && mkdir -p .policy-test-build && printf '{\"type\":\"commonjs\"}\\n' > .policy-test-build/package.json && tsc -p tsconfig.policy-tests.json && node .policy-test-build/tests/policy-ui.test.js",
"test:template-preview": "rm -rf .template-preview-test-build && mkdir -p .template-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .template-preview-test-build/package.json && tsc -p tsconfig.template-preview-tests.json && node .template-preview-test-build/tests/template-preview-draft.test.js",
"test:import-utils": "rm -rf .import-test-build && mkdir -p .import-test-build && printf '{\"type\":\"commonjs\"}\\n' > .import-test-build/package.json && tsc -p tsconfig.import-tests.json && node .import-test-build/tests/import-utils.test.js",
+ "test:recipient-search": "node tests/recipient-search-ui-structure.test.mjs",
"test:report-grid": "rm -rf .report-grid-test-build && mkdir -p .report-grid-test-build && printf '{\"type\":\"commonjs\"}\\n' > .report-grid-test-build/package.json && tsc -p tsconfig.report-grid-tests.json && node .report-grid-test-build/tests/report-grid-query.test.js",
"test:review-preview-ui": "rm -rf .review-preview-test-build && mkdir -p .review-preview-test-build && printf '{\"type\":\"commonjs\"}\\n' > .review-preview-test-build/package.json && tsc -p tsconfig.review-preview-tests.json && node .review-preview-test-build/tests/review-preview-ui.test.js && node tests/delivery-mode-ui-structure.test.mjs",
"test:operator-queue": "node --experimental-strip-types --test tests/operator-queue-model.test.ts && node tests/operator-queue-ui-structure.test.mjs",
diff --git a/webui/src/features/campaigns/CampaignReportPage.tsx b/webui/src/features/campaigns/CampaignReportPage.tsx
index f2b7028..8598e96 100644
--- a/webui/src/features/campaigns/CampaignReportPage.tsx
+++ b/webui/src/features/campaigns/CampaignReportPage.tsx
@@ -561,7 +561,9 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
Postbox targets{String(detail.job.postbox_target_count ?? 0)}
i18n:govoplan-campaign.imap_state.03b83be0
i18n:govoplan-campaign.attachments.6771ade6{String(detail.job.matched_file_count ?? detail.job.attachment_count ?? 0)}
+ Message SHA-256{String(detail.job.eml_sha256 ?? "—")}
+
@@ -585,6 +587,114 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
}
+type AttachmentEvidenceRow = {
+ id: string;
+ rule: string;
+ status: string;
+ delivery: string;
+ file: string;
+ assetId: string;
+ versionId: string;
+ sourceRevision: string;
+ checksum: string;
+ sizeBytes: number | null;
+};
+
+function AttachmentEvidenceSection({ attachments }: {attachments: unknown[];}) {
+ const rows = attachmentEvidenceRows(attachments);
+ const columns: DataGridColumn[] = [
+ { id: "rule", header: "Rule", width: 180, resizable: true, sortable: true, filterable: true, value: (row) => row.rule },
+ { id: "status", header: "Status", width: 130, sortable: true, filterable: true, render: (row) => , value: (row) => row.status },
+ { id: "delivery", header: "Attachment output", width: 220, resizable: true, filterable: true, value: (row) => row.delivery },
+ { id: "file", header: "Frozen file", width: "minmax(240px, 1fr)", minWidth: 220, resizable: true, filterable: true, value: (row) => row.file },
+ {
+ id: "version",
+ header: "Managed version",
+ width: 230,
+ resizable: true,
+ filterable: true,
+ value: (row) => `${row.versionId} ${row.assetId} ${row.sourceRevision}`,
+ render: (row) =>
+
+ {row.versionId || "Legacy source"}
+ {row.assetId && Asset {shortEvidenceId(row.assetId)}}
+ {row.sourceRevision && Source {shortEvidenceId(row.sourceRevision)}}
+
+ },
+ {
+ id: "checksum",
+ header: "SHA-256",
+ width: 170,
+ filterable: true,
+ value: (row) => row.checksum,
+ render: (row) => {row.checksum ? shortEvidenceId(row.checksum) : "—"}
+ },
+ { id: "size", header: "Size", width: 110, align: "right", sortable: true, value: (row) => row.sizeBytes ?? -1, render: (row) => row.sizeBytes === null ? "—" : `${row.sizeBytes.toLocaleString()} B` }
+ ];
+
+ return (
+
+ Frozen attachment evidence
+ {rows.length === 0 ?
+ No attachment rule or frozen file is recorded for this delivery job.
:
+ <>
+ Exact managed versions and checksums shown here are the immutable files used when this message was built.
+ row.id}
+ resizeBehavior="free" />
+ >
+ }
+
+ );
+}
+
+function attachmentEvidenceRows(attachments: unknown[]): AttachmentEvidenceRow[] {
+ const rows: AttachmentEvidenceRow[] = [];
+ attachments.forEach((value, ruleIndex) => {
+ const attachment = asRecord(value);
+ const rule = String(attachment.label ?? attachment.attachment_id ?? `Rule ${ruleIndex + 1}`);
+ const status = String(attachment.status ?? "unknown");
+ const zipEnabled = attachment.zip_enabled === true;
+ const delivery = zipEnabled
+ ? `ZIP ${String(attachment.zip_filename ?? attachment.zip_archive_id ?? "")}`.trim() + ` (${String(attachment.zip_mode ?? "inherit")})`
+ : "Direct attachment";
+ const managedMatches = Array.isArray(attachment.managed_matches) ? attachment.managed_matches.map(asRecord) : [];
+ const legacyMatches = Array.isArray(attachment.matches) ? attachment.matches : [];
+ const matches: Array | string | null> = managedMatches.length > 0
+ ? managedMatches
+ : legacyMatches.length > 0
+ ? legacyMatches.map((match) => typeof match === "string" ? match : asRecord(match))
+ : [null];
+
+ matches.forEach((match, matchIndex) => {
+ const managed = typeof match === "string" || match === null ? {} : match;
+ const filename = typeof match === "string"
+ ? match
+ : String(managed.display_path ?? managed.relative_path ?? managed.filename ?? "No matched file");
+ rows.push({
+ id: `${ruleIndex}:${matchIndex}:${String(managed.version_id ?? filename)}`,
+ rule,
+ status,
+ delivery,
+ file: filename,
+ assetId: String(managed.asset_id ?? ""),
+ versionId: String(managed.version_id ?? ""),
+ sourceRevision: String(managed.source_revision ?? ""),
+ checksum: String(managed.checksum_sha256 ?? ""),
+ sizeBytes: Number.isFinite(Number(managed.size_bytes)) ? Number(managed.size_bytes) : null
+ });
+ });
+ });
+ return rows;
+}
+
+function shortEvidenceId(value: string): string {
+ return value.length > 16 ? `${value.slice(0, 16)}...` : value;
+}
+
function AttemptHistoryTable({ kind, rows }: {kind: "smtp" | "imap" | "postbox";rows: Record[];}) {
const title = kind === "smtp"
? "i18n:govoplan-campaign.smtp_attempts.eb0a9ca6"
diff --git a/webui/src/features/campaigns/RecipientDataPage.tsx b/webui/src/features/campaigns/RecipientDataPage.tsx
index 99267ec..36ed816 100644
--- a/webui/src/features/campaigns/RecipientDataPage.tsx
+++ b/webui/src/features/campaigns/RecipientDataPage.tsx
@@ -24,7 +24,7 @@ import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { SegmentedControl } from "@govoplan/core-webui";
import { TableActionGroup } from "@govoplan/core-webui";
-import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "@govoplan/core-webui";
+import { DataGrid, DataGridEmptyAction, DataGridRowActions, type DataGridColumn, type DataGridQueryState } from "@govoplan/core-webui";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
@@ -124,6 +124,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
const [addressSources, setAddressSources] = useState([]);
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
+ const [recipientProfilesQuery, setRecipientProfilesQuery] = useState({ sort: null, filters: {} });
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState(null);
const [postboxTargetEditorIndex, setPostboxTargetEditorIndex] = useState(null);
const [postboxCatalog, setPostboxCatalog] = useState({
@@ -526,6 +527,7 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
fieldDefinitions,
individualAttachmentBasePaths,
zipConfig,
+ addressFilter: recipientProfilesQuery.filters.recipients ?? "",
translateText,
openAddressEditor: setRecipientAddressEditorIndex,
openPostboxTargetEditor: setPostboxTargetEditorIndex,
@@ -549,7 +551,8 @@ export default function RecipientDataPage({ settings, campaignId }: {settings: A
setRecipientProfilesPageSize(pageSize);
setRecipientProfilesPage(1);
}
- }} />
+ }}
+ onQueryChange={setRecipientProfilesQuery} />
}
@@ -1735,6 +1738,7 @@ type RecipientProfileColumnContext = {
fieldDefinitions: ReturnType;
individualAttachmentBasePaths: ReturnType;
zipConfig: AttachmentZipCollection;
+ addressFilter: string;
translateText: (value: string) => string;
openAddressEditor: (index: number) => void;
openPostboxTargetEditor: (index: number) => void;
@@ -1746,7 +1750,7 @@ type RecipientProfileColumnContext = {
removeEntry: (index: number) => void;
};
-function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn>[] {
+function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, postboxModuleInstalled, postboxCatalog, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, addressFilter, translateText, openAddressEditor, openPostboxTargetEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn>[] {
return [
{
id: "number",
@@ -1770,6 +1774,7 @@ function recipientProfileColumns({ settings, campaignId, draft, locked, filesMod
filterable: true,
render: (entry, index) => {
const summary = recipientAddressSummary(entry, translateText);
+ const hiddenMatch = hiddenRecipientAddressMatch(entry, addressFilter);
const content = (
<>
@@ -1777,6 +1782,11 @@ function recipientProfileColumns({ settings, campaignId, draft, locked, filesMod
{summary.badges.map((badge) =>
{badge}
)}
+ {hiddenMatch &&
+
+ Matched in {hiddenMatch.label}: {hiddenMatch.address}
+
+ }
{!locked && }
>
@@ -1936,6 +1946,32 @@ function recipientAddressFilterValue(entry: Record): string {
join(", ");
}
+function hiddenRecipientAddressMatch(
+ entry: Record,
+ filterValue: string
+): {label: string;address: string;} | null {
+ const query = filterValue.trim().toLowerCase();
+ if (!query) return null;
+
+ const primary = getEntryAddresses(entry, "to")[0];
+ if (primary && formatMailboxAddress(primary).toLowerCase().includes(query)) return null;
+
+ const labels: Record = {
+ from: "From",
+ reply_to: "Reply-To",
+ to: "To",
+ cc: "CC",
+ bcc: "BCC"
+ };
+ for (const column of recipientAddressOverlayColumns) {
+ const addresses = getEntryAddresses(entry, column.key);
+ const hiddenAddresses = column.key === "to" ? addresses.slice(1) : addresses;
+ const match = hiddenAddresses.find((address) => formatMailboxAddress(address).toLowerCase().includes(query));
+ if (match) return { label: labels[column.key], address: formatMailboxAddress(match) };
+ }
+ return null;
+}
+
function addressCountBadge(count: number, label: string): string | null {
if (count <= 0) return null;
return `+${count} ${label}`;
diff --git a/webui/src/styles/campaign-workspace.css b/webui/src/styles/campaign-workspace.css
index 77418e2..39a1514 100644
--- a/webui/src/styles/campaign-workspace.css
+++ b/webui/src/styles/campaign-workspace.css
@@ -930,9 +930,20 @@
align-items: center;
gap: 6px;
min-width: 0;
+ flex: 1 1 auto;
flex-wrap: wrap;
}
+.recipient-hidden-address-match {
+ flex-basis: 100%;
+ min-width: 0;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: var(--font-size-sm);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
.recipient-data-address.is-empty {
color: var(--muted);
font-style: italic;
@@ -2380,6 +2391,28 @@
margin: 0;
}
+.campaign-attachment-evidence .data-grid-shell {
+ max-height: 300px;
+}
+
+.campaign-evidence-identifiers {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+}
+
+.campaign-evidence-identifiers code,
+.campaign-evidence-identifiers small {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.campaign-evidence-identifiers small {
+ color: var(--muted);
+}
+
.attempt-history-wrap {
overflow: auto;
border: var(--border-line);
diff --git a/webui/tests/recipient-search-ui-structure.test.mjs b/webui/tests/recipient-search-ui-structure.test.mjs
new file mode 100644
index 0000000..b5faea8
--- /dev/null
+++ b/webui/tests/recipient-search-ui-structure.test.mjs
@@ -0,0 +1,28 @@
+import { readFileSync } from "node:fs";
+
+const source = readFileSync(
+ new URL("../src/features/campaigns/RecipientDataPage.tsx", import.meta.url),
+ "utf8"
+).replace(/\s+/g, " ");
+
+function assertIncludes(fragment, message) {
+ if (!source.includes(fragment.replace(/\s+/g, " "))) throw new Error(message);
+}
+
+for (const field of ["from", "reply_to", "to", "cc", "bcc"]) {
+ assertIncludes(`key: "${field}"`, `recipient search must retain the ${field} address category`);
+}
+assertIncludes(
+ "recipientAddressOverlayColumns. flatMap((column) => getEntryAddresses(entry, column.key))",
+ "recipient filtering must search every configured address category"
+);
+assertIncludes(
+ "onQueryChange={setRecipientProfilesQuery}",
+ "the recipient page must observe the shared DataGrid filter"
+);
+assertIncludes(
+ "Matched in {hiddenMatch.label}: {hiddenMatch.address}",
+ "a row must explain when its match came from a hidden address"
+);
+
+console.log("Campaign recipient search covers and explains hidden address matches.");
diff --git a/webui/tests/report-grid-query.test.ts b/webui/tests/report-grid-query.test.ts
index 66238d5..982ed55 100644
--- a/webui/tests/report-grid-query.test.ts
+++ b/webui/tests/report-grid-query.test.ts
@@ -86,3 +86,6 @@ assert(reportSource.includes('return status === "skipped" ? "i18n:govoplan-campa
assert(reportSource.includes("cards?.skipped ?? jobs.counts.send?.skipped"), "SMTP skipped has a separate report count");
assert(reportSource.includes("cards?.imap_skipped ?? jobs.counts.imap?.skipped"), "IMAP skipped has a separate report count");
assert(!reportSource.includes("setPage((value) => Math.max(1, value - 1))"), "the one-off report pager is removed in favor of the central DataGrid pager");
+assert(reportSource.includes("