From 77c68fd7622bd6ff4c4280ff8561e24836af82a8 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Thu, 22 Oct 2026 12:44:16 -0400 Subject: [PATCH] Let the operator choose which contacts receive a campaign Co-Authored-By: Claude Sonnet 5.5 --- MassMailer.hta | 99 ++++++++++++++++++++++++++++++++++-- tools/campaign-common.ps1 | 34 +++++++++++-- tools/test-campaign-flow.ps1 | 13 +++++ 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/MassMailer.hta b/MassMailer.hta index d7c1b84..e7f4792 100644 --- a/MassMailer.hta +++ b/MassMailer.hta @@ -43,6 +43,7 @@ var pendingPath = ""; var pendingCampaignReview = null; var campaignTestAccepted = false; + var recipientSelection = {}; var dispatchSessionPid = 0; var dispatchSessionToken = ""; var dispatchSessionPath = ""; @@ -232,7 +233,7 @@ document.getElementById("importMessage").innerText = "Import failed: " + error.message; } finally { if (db !== null && db.State === 1) db.Close(); - if (succeeded) refreshStatus(); + if (succeeded) { refreshStatus(); refreshRecipients(); } } } function addSuppression() { @@ -261,7 +262,7 @@ document.getElementById("suppressMessage").innerText = "Suppression failed: " + error.message; } finally { if (db !== null && db.State === 1) db.Close(); - if (succeeded) refreshStatus(); + if (succeeded) { refreshStatus(); refreshRecipients(); } } } function powerShellHost() { @@ -460,6 +461,87 @@ if (db !== null && db.State === 1) db.Close(); } } + function selectedRecipientIds() { + var ids = []; + for (var id in recipientSelection) { + if (recipientSelection.hasOwnProperty(id) && recipientSelection[id]) ids.push(Number(id)); + } + return ids; + } + function updateRecipientCount() { + var total = 0; + for (var id in recipientSelection) if (recipientSelection.hasOwnProperty(id)) total++; + document.getElementById("recipientCount").innerText = + selectedRecipientIds().length + " of " + total + " eligible contacts selected"; + } + function toggleRecipient(box) { + recipientSelection[box.value] = box.checked; + updateRecipientCount(); + invalidateCampaignReview(); + } + function setVisibleRecipients(checked) { + var boxes = document.getElementById("recipientList").getElementsByTagName("input"); + for (var i = 0; i < boxes.length; i++) { + if (boxes[i].parentNode.parentNode.style.display === "none") continue; + boxes[i].checked = checked; + recipientSelection[boxes[i].value] = checked; + } + updateRecipientCount(); + invalidateCampaignReview(); + } + function filterRecipients() { + var needle = document.getElementById("recipientFilter").value.toLowerCase(); + var rows = document.getElementById("recipientList").getElementsByTagName("tr"); + for (var i = 0; i < rows.length; i++) { + rows[i].style.display = + rows[i].getAttribute("data-text").indexOf(needle) === -1 ? "none" : ""; + } + } + function refreshRecipients() { + var db = null; + try { + db = openDatabase().db; + var rows = db.Execute("SELECT c.[Id],c.[EmailOriginal],c.[BusinessName] FROM [Contact] AS c " + + "LEFT JOIN [Suppression] AS s ON c.[EmailKey]=s.[EmailKey] WHERE s.[EmailKey] IS NULL " + + "AND c.[Country]='US' AND c.[EligibilityBasis]='public_listing' ORDER BY c.[Id]"); + var body = document.getElementById("recipientList"); + while (body.firstChild) body.removeChild(body.firstChild); + var known = recipientSelection; + recipientSelection = {}; + while (!rows.EOF) { + var id = String(rows.Fields("Id").Value); + var email = String(rows.Fields("EmailOriginal").Value); + var business = rows.Fields("BusinessName").Value; + business = business === null ? "" : String(business); + recipientSelection[id] = known.hasOwnProperty(id) ? known[id] : true; + var tr = document.createElement("tr"); + tr.setAttribute("data-text", (email + " " + business).toLowerCase()); + var boxCell = document.createElement("td"); + var box = document.createElement("input"); + box.type = "checkbox"; + box.value = id; + box.checked = recipientSelection[id]; + box.onclick = function () { toggleRecipient(this); }; + boxCell.appendChild(box); + var emailCell = document.createElement("td"); + emailCell.innerText = email; + var businessCell = document.createElement("td"); + businessCell.innerText = business; + tr.appendChild(boxCell); + tr.appendChild(emailCell); + tr.appendChild(businessCell); + body.appendChild(tr); + rows.MoveNext(); + } + rows.Close(); + updateRecipientCount(); + filterRecipients(); + } catch (error) { + document.getElementById("campaignMessage").innerText = "Recipient list failed: " + error.message; + } finally { + if (db !== null && db.State === 1) db.Close(); + } + } function invalidateCampaignReview() { pendingCampaignReview = null; campaignTestAccepted = false; @@ -475,6 +557,7 @@ Name: document.getElementById("campaignName").value, Subject: document.getElementById("campaignSubject").value, TemplateId: document.getElementById("campaignTemplateVersion").value, + ContactIds: selectedRecipientIds(), ReviewToken: pendingCampaignReview ? pendingCampaignReview.Token : "", TestReceived: document.getElementById("confirmReceived").checked }; @@ -577,6 +660,7 @@ refreshStatus(); refreshTemplates(); refreshCampaigns(); + refreshRecipients(); initializeDispatch(); } catch (error) { document.getElementById("dispatchMessage").innerText = @@ -866,10 +950,19 @@

Review and approve a campaign

-

Approval captures currently eligible contacts and the selected template version. It requires a complete postal address in config.ini, a campaign-specific internal test, and your confirmation that you received it. Approved campaigns stay paused until you resume them.

+

Approval captures the contacts you select below (from currently eligible, unsuppressed contacts) and the selected template version. It requires a complete postal address in config.ini, a campaign-specific internal test, and your confirmation that you received it. Approved campaigns stay paused until you resume them.

+

Recipients

+ + + + +
+
+
+
diff --git a/tools/campaign-common.ps1 b/tools/campaign-common.ps1 index ae99bd1..53569ab 100644 --- a/tools/campaign-common.ps1 +++ b/tools/campaign-common.ps1 @@ -4,7 +4,7 @@ $ErrorActionPreference = 'Stop' function Read-CampaignRequest { param([string]$Path) if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw 'Campaign request file not found' } - if ((Get-Item -LiteralPath $Path).Length -gt 8192) { throw 'Campaign request is too large' } + if ((Get-Item -LiteralPath $Path).Length -gt 16384) { throw 'Campaign request is too large' } $request = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json $name = ([string]$request.Name).Trim() $subject = ([string]$request.Subject).Trim() @@ -18,7 +18,21 @@ function Read-CampaignRequest { if ($subject.Length -lt 1 -or $subject.Length -gt 240 -or $subject -match '[\x00-\x1f]') { throw 'Subject must be 1-240 characters without control characters' } - return @{ Name = $name; Subject = $subject; TemplateId = $id; + # Absent ContactIds means every eligible contact; a present list limits the campaign to those ids. + $contactIds = $null + if ($null -ne $request.ContactIds) { + $seen = @{} + foreach ($value in @($request.ContactIds)) { + $contactId = 0 + if (-not [int]::TryParse([string]$value, [ref]$contactId) -or $contactId -lt 1) { + throw 'Invalid contact selection' + } + $seen[$contactId] = $true + } + if ($seen.Count -lt 1) { throw 'Select at least one recipient' } + $contactIds = @($seen.Keys | Sort-Object) + } + return @{ Name = $name; Subject = $subject; TemplateId = $id; ContactIds = $contactIds; ReviewToken = ([string]$request.ReviewToken).Trim(); TestReceived = ($request.TestReceived -eq $true) } } @@ -51,7 +65,7 @@ function New-DbCommand { } function Get-EligibleContacts { - param($Connection, $Transaction = $null) + param($Connection, $Transaction = $null, $ContactIds = $null) $command = New-DbCommand $Connection "SELECT c.[Id],c.[EmailOriginal],c.[EmailKey] FROM [Contact] AS c LEFT JOIN [Suppression] AS s ON c.[EmailKey]=s.[EmailKey] WHERE s.[EmailKey] IS NULL AND c.[Country]='US' AND c.[EligibilityBasis]='public_listing' ORDER BY c.[Id]" $Transaction $reader = $command.ExecuteReader() $contacts = New-Object System.Collections.ArrayList @@ -62,6 +76,18 @@ function Get-EligibleContacts { } } finally { $reader.Dispose() } + if ($null -ne $ContactIds) { + $eligible = @{} + foreach ($contact in $contacts) { $eligible[$contact.Id] = $contact } + $selected = New-Object System.Collections.ArrayList + foreach ($contactId in $ContactIds) { + if (-not $eligible.ContainsKey([int]$contactId)) { + throw 'A selected contact is no longer eligible or was suppressed. Review again.' + } + $null = $selected.Add($eligible[[int]$contactId]) + } + $contacts = $selected + } if ($contacts.Count -lt 1) { throw 'No eligible, unsuppressed US contacts are available' } if ($contacts.Count -gt 500) { throw 'This version supports at most 500 eligible contacts per campaign' } return ,$contacts @@ -145,7 +171,7 @@ function Get-CampaignReview { param($Context, $Request, $Connection, $Transaction = $null) $identity = Get-CampaignIdentity $Context $template = Get-TemplateSnapshot $Connection $Request.TemplateId $Transaction - $contacts = Get-EligibleContacts $Connection $Transaction + $contacts = Get-EligibleContacts $Connection $Transaction $Request.ContactIds $html = Add-CampaignFooter $template.Document $identity $parts = New-Object System.Collections.ArrayList foreach ($value in @($Request.Name, $Request.Subject, [string]$Request.TemplateId, diff --git a/tools/test-campaign-flow.ps1 b/tools/test-campaign-flow.ps1 index c0987b6..6f58271 100644 --- a/tools/test-campaign-flow.ps1 +++ b/tools/test-campaign-flow.ps1 @@ -54,6 +54,19 @@ try { $previewHtml = Get-Content -LiteralPath $review.PreviewPath -Raw if ($previewHtml -notmatch '123 Example Street' -or $previewHtml -notmatch 'UNSUBSCRIBE' -or $previewHtml -notmatch 'image-1.png') { throw 'Full preview is missing footer or image' } + $subset = $request.Clone() + $subset.ContactIds = @(1, 3) + Save-Request $subset + $subsetReview = (& $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'review-campaign.ps1') -RequestPath $requestPath -ConfigPath $configPath -OutputRoot (Join-Path $work 'previews')) | ConvertFrom-Json + if ($LASTEXITCODE -ne 0 -or $subsetReview.Count -ne 2 -or $subsetReview.Token -eq $review.Token) { + throw 'Contact selection did not limit the review' + } + $subset.ContactIds = @() + Save-Request $subset + Expect-Failure (Join-Path $PSScriptRoot 'review-campaign.ps1') @('-RequestPath',$requestPath,'-ConfigPath',$configPath) + $subset.ContactIds = @(1, 999) + Save-Request $subset + Expect-Failure (Join-Path $PSScriptRoot 'review-campaign.ps1') @('-RequestPath',$requestPath,'-ConfigPath',$configPath) $request.ReviewToken = $review.Token Save-Request $request Expect-Failure (Join-Path $PSScriptRoot 'approve-campaign.ps1') @('-RequestPath',$requestPath,'-ConfigPath',$configPath)