소스 검색

Let the operator choose which contacts receive a campaign

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
master
Daniel Covington 1 일 전
부모
커밋
77c68fd762
3개의 변경된 파일과 139개의 추가작업 그리고 7개의 파일을 삭제
  1. +96
    -3
      MassMailer.hta
  2. +30
    -4
      tools/campaign-common.ps1
  3. +13
    -0
      tools/test-campaign-flow.ps1

+ 96
- 3
MassMailer.hta 파일 보기

@@ -43,6 +43,7 @@
var pendingPath = ""; var pendingPath = "";
var pendingCampaignReview = null; var pendingCampaignReview = null;
var campaignTestAccepted = false; var campaignTestAccepted = false;
var recipientSelection = {};
var dispatchSessionPid = 0; var dispatchSessionPid = 0;
var dispatchSessionToken = ""; var dispatchSessionToken = "";
var dispatchSessionPath = ""; var dispatchSessionPath = "";
@@ -232,7 +233,7 @@
document.getElementById("importMessage").innerText = "Import failed: " + error.message; document.getElementById("importMessage").innerText = "Import failed: " + error.message;
} finally { } finally {
if (db !== null && db.State === 1) db.Close(); if (db !== null && db.State === 1) db.Close();
if (succeeded) refreshStatus();
if (succeeded) { refreshStatus(); refreshRecipients(); }
} }
} }
function addSuppression() { function addSuppression() {
@@ -261,7 +262,7 @@
document.getElementById("suppressMessage").innerText = "Suppression failed: " + error.message; document.getElementById("suppressMessage").innerText = "Suppression failed: " + error.message;
} finally { } finally {
if (db !== null && db.State === 1) db.Close(); if (db !== null && db.State === 1) db.Close();
if (succeeded) refreshStatus();
if (succeeded) { refreshStatus(); refreshRecipients(); }
} }
} }
function powerShellHost() { function powerShellHost() {
@@ -460,6 +461,87 @@
if (db !== null && db.State === 1) db.Close(); 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() { function invalidateCampaignReview() {
pendingCampaignReview = null; pendingCampaignReview = null;
campaignTestAccepted = false; campaignTestAccepted = false;
@@ -475,6 +557,7 @@
Name: document.getElementById("campaignName").value, Name: document.getElementById("campaignName").value,
Subject: document.getElementById("campaignSubject").value, Subject: document.getElementById("campaignSubject").value,
TemplateId: document.getElementById("campaignTemplateVersion").value, TemplateId: document.getElementById("campaignTemplateVersion").value,
ContactIds: selectedRecipientIds(),
ReviewToken: pendingCampaignReview ? pendingCampaignReview.Token : "", ReviewToken: pendingCampaignReview ? pendingCampaignReview.Token : "",
TestReceived: document.getElementById("confirmReceived").checked TestReceived: document.getElementById("confirmReceived").checked
}; };
@@ -577,6 +660,7 @@
refreshStatus(); refreshStatus();
refreshTemplates(); refreshTemplates();
refreshCampaigns(); refreshCampaigns();
refreshRecipients();
initializeDispatch(); initializeDispatch();
} catch (error) { } catch (error) {
document.getElementById("dispatchMessage").innerText = document.getElementById("dispatchMessage").innerText =
@@ -866,10 +950,19 @@
</div> </div>
<div class="panel tab-pane" data-tab="campaigns" role="tabpanel"> <div class="panel tab-pane" data-tab="campaigns" role="tabpanel">
<h2>Review and approve a campaign</h2> <h2>Review and approve a campaign</h2>
<p>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.</p>
<p>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.</p>
<label class="field">Campaign name <input id="campaignName" type="text" maxlength="200" oninput="invalidateCampaignReview()" /></label> <label class="field">Campaign name <input id="campaignName" type="text" maxlength="200" oninput="invalidateCampaignReview()" /></label>
<label class="field">Email subject <input id="campaignSubject" type="text" maxlength="240" oninput="invalidateCampaignReview()" /></label> <label class="field">Email subject <input id="campaignSubject" type="text" maxlength="240" oninput="invalidateCampaignReview()" /></label>
<label class="field">Template version <select id="campaignTemplateVersion" onchange="invalidateCampaignReview()"></select></label> <label class="field">Template version <select id="campaignTemplateVersion" onchange="invalidateCampaignReview()"></select></label>
<h3>Recipients</h3>
<label class="field">Filter by email or business <input id="recipientFilter" type="text" oninput="filterRecipients()" /></label>
<button onclick="setVisibleRecipients(true)">Select shown</button>
<button onclick="setVisibleRecipients(false)">Clear shown</button>
<button onclick="refreshRecipients()">Reload contacts</button>
<div id="recipientCount"></div>
<div style="max-height:220px;overflow:auto;border:1px solid #dbe1e7;background:white">
<table style="margin-top:0"><tbody id="recipientList"></tbody></table>
</div>
<button onclick="reviewCampaign()">Review message and recipients</button> <button onclick="reviewCampaign()">Review message and recipients</button>
<div id="campaignReview"></div> <div id="campaignReview"></div>
<iframe id="campaignPreview" APPLICATION="no" title="Full campaign preview"></iframe> <iframe id="campaignPreview" APPLICATION="no" title="Full campaign preview"></iframe>


+ 30
- 4
tools/campaign-common.ps1 파일 보기

@@ -4,7 +4,7 @@ $ErrorActionPreference = 'Stop'
function Read-CampaignRequest { function Read-CampaignRequest {
param([string]$Path) param([string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw 'Campaign request file not found' } 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 $request = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
$name = ([string]$request.Name).Trim() $name = ([string]$request.Name).Trim()
$subject = ([string]$request.Subject).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]') { 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' 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) } ReviewToken = ([string]$request.ReviewToken).Trim(); TestReceived = ($request.TestReceived -eq $true) }
} }


@@ -51,7 +65,7 @@ function New-DbCommand {
} }


function Get-EligibleContacts { 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 $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() $reader = $command.ExecuteReader()
$contacts = New-Object System.Collections.ArrayList $contacts = New-Object System.Collections.ArrayList
@@ -62,6 +76,18 @@ function Get-EligibleContacts {
} }
} }
finally { $reader.Dispose() } 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 -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' } if ($contacts.Count -gt 500) { throw 'This version supports at most 500 eligible contacts per campaign' }
return ,$contacts return ,$contacts
@@ -145,7 +171,7 @@ function Get-CampaignReview {
param($Context, $Request, $Connection, $Transaction = $null) param($Context, $Request, $Connection, $Transaction = $null)
$identity = Get-CampaignIdentity $Context $identity = Get-CampaignIdentity $Context
$template = Get-TemplateSnapshot $Connection $Request.TemplateId $Transaction $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 $html = Add-CampaignFooter $template.Document $identity
$parts = New-Object System.Collections.ArrayList $parts = New-Object System.Collections.ArrayList
foreach ($value in @($Request.Name, $Request.Subject, [string]$Request.TemplateId, foreach ($value in @($Request.Name, $Request.Subject, [string]$Request.TemplateId,


+ 13
- 0
tools/test-campaign-flow.ps1 파일 보기

@@ -54,6 +54,19 @@ try {
$previewHtml = Get-Content -LiteralPath $review.PreviewPath -Raw $previewHtml = Get-Content -LiteralPath $review.PreviewPath -Raw
if ($previewHtml -notmatch '123 Example Street' -or $previewHtml -notmatch 'UNSUBSCRIBE' -or if ($previewHtml -notmatch '123 Example Street' -or $previewHtml -notmatch 'UNSUBSCRIBE' -or
$previewHtml -notmatch 'image-1.png') { throw 'Full preview is missing footer or image' } $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 $request.ReviewToken = $review.Token
Save-Request $request Save-Request $request
Expect-Failure (Join-Path $PSScriptRoot 'approve-campaign.ps1') @('-RequestPath',$requestPath,'-ConfigPath',$configPath) Expect-Failure (Join-Path $PSScriptRoot 'approve-campaign.ps1') @('-RequestPath',$requestPath,'-ConfigPath',$configPath)


불러오는 중...
취소
저장

Powered by TurnKey Linux.