Pārlūkot izejas kodu

Add tabbed HTA workflow and guarded campaign dispatch

master
Daniel Covington pirms 1 dienas
vecāks
revīzija
a3e1190536
20 mainītis faili ar 1213 papildinājumiem un 30 dzēšanām
  1. +1
    -0
      .gitignore
  2. +2
    -2
      .specs/features/mass-mailer/tasks.md
  3. +1
    -1
      .specs/features/mass-mailer/test-plan.md
  4. +328
    -12
      MassMailer.hta
  5. +9
    -5
      README.md
  6. +2
    -0
      config.example.ini
  7. +1
    -1
      templates/README.md
  8. +1
    -1
      tools/common.vbs
  9. +191
    -0
      tools/dispatch-common.ps1
  10. +129
    -0
      tools/dispatch-control.ps1
  11. +192
    -0
      tools/dispatch-once.ps1
  12. +86
    -0
      tools/export-report.ps1
  13. +2
    -1
      tools/preview-template.ps1
  14. +2
    -1
      tools/review-campaign.ps1
  15. +72
    -0
      tools/send-approved-campaign-test.ps1
  16. +2
    -2
      tools/test-campaign-db.ps1
  17. +9
    -4
      tools/test-campaign-flow.ps1
  18. +125
    -0
      tools/test-dispatch-flow.ps1
  19. +50
    -0
      tools/test-hta-startup.ps1
  20. +8
    -0
      tools/test-session-guard.ps1

+ 1
- 0
.gitignore Parādīt failu

@@ -20,6 +20,7 @@
/templates/local/
/logs/
/backups/
/reports/
/temp/
/tmp/



+ 2
- 2
.specs/features/mass-mailer/tasks.md Parādīt failu

@@ -12,7 +12,7 @@
| F06 ✅ | Contact import and suppression controls | F02, F04 | Disposable MDB integration test passes import, duplicate rejection, re-import rejection, and suppression. |
| F07 ✅ | HTML template import and safe preview | F02, F04 | Disposable MDB test passes image snapshot and preview after deleting source; unsafe paths, active content, and CSS image URLs fail. Preview does not yet include campaign footer. |
| F08 ◐ | Campaign approval and test send | F05-F07 | Disposable MDB test passes full footer preview, suppression-sensitive review token, internal-only MIME, and paused approval. Real postal address and campaign-specific inbox test pending. |
| F09 | In-app dispatch, pacing, pause, and recovery | F08 | Fake adapter tests cover gap, close/reopen, suppression, and uncertain outcomes. |
| F10 | Reporting, backup, and pilot readiness | F09 | Export, restore drill, and manual pilot checklist pass. |
| F09 ✅ | In-app dispatch, pacing, pause, and recovery | F08 | Disposable-MDB pickup tests cover the gap, restart, suppression, uncertain outcomes, and reconciliation; live owner pilot remains a release gate. |
| F10 ◐ | Reporting, backup, and pilot readiness | F09 | CSV export passes a disposable-MDB test. Backup, restore drill, and owner pilot remain open. |

Implementation uses built-in Windows script hosts and Access COM components already present on the target PC. No third-party dependency is planned.

+ 1
- 1
.specs/features/mass-mailer/test-plan.md Parādīt failu

@@ -45,7 +45,7 @@

## Release gates

On 2026-10-22, the owner reported that password entry and the one-message authenticated SMTP transport test worked. The specific received-image rendering result was not captured. The campaign-specific test and live dispatch gates remain open.
On 2026-10-22, the owner reported that password entry and the one-message authenticated SMTP transport test worked. The specific received-image rendering result was not captured. Preapproval and postapproval campaign tests pass through local pickup, and dispatch controls pass disposable-MDB pickup tests. Owner inbox review, live pilot, backup, and restore gates remain open.

1. All P1 stories and T01-T32 applicable to the chosen provider pass.
2. Sender domain and provider limits verified; the system can display provider rejection and halt safely.


+ 328
- 12
MassMailer.hta Parādīt failu

@@ -10,6 +10,12 @@
h1 { margin: 0; font-size: 24px; }
header p { margin: 7px 0 0; color: #d2dfec; }
main { padding: 24px 28px; }
.tabbar { margin: 0 0 20px; border-bottom: 2px solid #cbd7e2; }
.tab-button { margin: 0 4px 0 0; padding: 11px 15px; background: transparent; color: #17324e; border: 1px solid transparent; border-bottom: 0; font-weight: bold; }
.tab-button:hover, .tab-button:focus { background: #e8eff5; }
.tab-button.active { background: white; border-color: #cbd7e2; color: #105b88; }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
.notice { padding: 14px 17px; background: #fff7e3; border-left: 4px solid #ca8f13; margin-bottom: 22px; }
.cards { display: table; width: 100%; border-spacing: 10px; margin-left: -10px; }
.card { display: table-cell; width: 25%; background: white; padding: 18px; border: 1px solid #dbe1e7; }
@@ -37,6 +43,12 @@
var pendingPath = "";
var pendingCampaignReview = null;
var campaignTestAccepted = false;
var dispatchSessionPid = 0;
var dispatchSessionToken = "";
var dispatchSessionPath = "";
var dispatchReady = false;
var dispatchProcess = null;
var dispatchTimerId = null;

function projectRoot() {
var path = decodeURIComponent(window.location.pathname);
@@ -94,8 +106,6 @@
document.getElementById("interval").innerText = opened.config.QUEUE_INTERVAL + " seconds";
document.getElementById("testTo").innerText = opened.config.SMTP_TEST_TO || "Not configured";
document.getElementById("error").innerText = "";
refreshTemplates();
refreshCampaigns();
} catch (error) {
document.getElementById("error").innerText = "Status refresh failed: " + error.message;
} finally {
@@ -329,8 +339,9 @@
function runTemplateTool(scriptName, args) {
var fs = new ActiveXObject("Scripting.FileSystemObject");
var script = fs.BuildPath(projectRoot(), "tools\\" + scriptName);
var configPath = fs.BuildPath(projectRoot(), "config.ini");
var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " +
quoted(script) + " " + args;
quoted(script) + " -ConfigPath " + quoted(configPath) + " " + args;
var process = new ActiveXObject("WScript.Shell").Exec(command);
var output = process.StdOut.ReadAll();
var errors = process.StdErr.ReadAll();
@@ -368,6 +379,12 @@
var rows = db.Execute("SELECT [Id],[Name],[Status] FROM [Campaign] ORDER BY [Id] DESC");
var body = document.getElementById("campaignBody");
while (body.firstChild) body.removeChild(body.firstChild);
var select = document.getElementById("dispatchCampaign");
var reportSelect = document.getElementById("reportCampaign");
var selectedCampaign = select.value;
var selectedReport = reportSelect.value;
while (select.options.length) select.remove(0);
while (reportSelect.options.length) reportSelect.remove(0);
while (!rows.EOF) {
var tr = document.createElement("tr");
var values = [rows.Fields("Id").Value, rows.Fields("Name").Value,
@@ -378,9 +395,27 @@
tr.appendChild(td);
}
body.appendChild(tr);
var reportOption = document.createElement("option");
reportOption.value = String(values[0]);
reportOption.text = "#" + values[0] + " " + values[1];
reportSelect.add(reportOption);
var status = String(values[2]);
if (status === "approved_paused" || status === "running") {
var option = document.createElement("option");
option.value = String(values[0]);
option.text = "#" + values[0] + " " + values[1] + " (" + status + ")";
select.add(option);
}
rows.MoveNext();
}
rows.Close();
for (var d = 0; d < select.options.length; d++) {
if (select.options[d].value === selectedCampaign) select.selectedIndex = d;
}
for (var r = 0; r < reportSelect.options.length; r++) {
if (reportSelect.options[r].value === selectedReport) reportSelect.selectedIndex = r;
}
refreshUncertain();
} catch (error) {
document.getElementById("campaignMessage").innerText = "Campaign list failed: " + error.message;
} finally {
@@ -470,16 +505,273 @@
document.getElementById("campaignMessage").innerText = result;
invalidateCampaignReview();
refreshStatus();
refreshCampaigns();
} catch (error) {
document.getElementById("campaignMessage").innerText = "Approval failed: " + error.message;
}
}
function htaProcessId() {
var service = new ActiveXObject("WbemScripting.SWbemLocator")
.ConnectServer(".", "root\\cimv2");
var rows = new Enumerator(service.ExecQuery(
"SELECT ProcessId,CommandLine FROM Win32_Process WHERE Name='mshta.exe'"));
var path = projectRoot().toLowerCase();
for (; !rows.atEnd(); rows.moveNext()) {
var row = rows.item();
var command = String(row.CommandLine).toLowerCase();
if (command.indexOf("massmailer.hta") !== -1 && command.indexOf(path) !== -1)
return Number(row.ProcessId);
}
throw new Error("Could not identify this HTA process for the open-window send guard");
}
function writeHeartbeat() {
if (!dispatchReady) return;
var fs = new ActiveXObject("Scripting.FileSystemObject");
var file = fs.CreateTextFile(dispatchSessionPath, true);
file.Write(dispatchSessionToken);
file.Close();
}
function startup() {
try {
showTab("overview");
refreshStatus();
refreshTemplates();
refreshCampaigns();
initializeDispatch();
} catch (error) {
document.getElementById("dispatchMessage").innerText =
"Startup failed: " + error.message;
}
}
function showTab(name) {
var panes = document.querySelectorAll(".tab-pane");
var buttons = document.querySelectorAll(".tab-button");
for (var i = 0; i < panes.length; i++) {
var paneActive = panes[i].getAttribute("data-tab") === name;
var paneBase = panes[i].getAttribute("data-tab") === "overview" ? "" : "panel ";
panes[i].className = paneBase + (paneActive ? "tab-pane active" : "tab-pane");
panes[i].setAttribute("aria-hidden", paneActive ? "false" : "true");
}
for (var j = 0; j < buttons.length; j++) {
var buttonActive = buttons[j].getAttribute("data-tab") === name;
buttons[j].className = buttonActive ? "tab-button active" : "tab-button";
buttons[j].setAttribute("aria-selected", buttonActive ? "true" : "false");
}
window.scrollTo(0, 0);
}
function initializeDispatch() {
if (dispatchReady) return;
try {
var fs = new ActiveXObject("Scripting.FileSystemObject");
var folder = fs.BuildPath(projectRoot(), "temp");
if (!fs.FolderExists(folder)) fs.CreateFolder(folder);
document.getElementById("dispatchMessage").innerText =
runTemplateTool("dispatch-control.ps1", "-Action Recover");
dispatchSessionPid = htaProcessId();
dispatchSessionToken = "";
for (var i = 0; i < 32; i++)
dispatchSessionToken += "0123456789abcdef".charAt(Math.floor(Math.random() * 16));
dispatchSessionPath = fs.BuildPath(folder, "session-" + dispatchSessionPid + ".txt");
dispatchReady = true;
writeHeartbeat();
if (dispatchTimerId === null)
dispatchTimerId = window.setInterval(dispatchTick, 5000);
document.getElementById("retryDispatch").disabled = true;
document.getElementById("reviewOptOut").disabled = false;
document.getElementById("resumeCampaign").disabled = false;
document.getElementById("pauseAll").disabled = false;
document.getElementById("cancelCampaign").disabled = false;
refreshCampaigns();
refreshDispatchStatus();
} catch (error) {
dispatchReady = false;
document.getElementById("dispatchMessage").innerText =
"Dispatcher did not initialize: " + error.message;
}
}
function shutdownDispatch() {
dispatchReady = false;
try {
if (dispatchTimerId !== null) window.clearInterval(dispatchTimerId);
var fs = new ActiveXObject("Scripting.FileSystemObject");
if (dispatchSessionPath && fs.FileExists(dispatchSessionPath))
fs.DeleteFile(dispatchSessionPath);
runTemplateTool("dispatch-control.ps1", "-Action PauseAll");
} catch (ignored) { }
}
function refreshDispatchStatus() {
if (!dispatchReady || (dispatchProcess && dispatchProcess.Status === 0)) return;
try {
var status = JSON.parse(runTemplateTool("dispatch-control.ps1", "-Action Status"));
document.getElementById("sendState").innerText = status.Paused ?
"Paused" : "Running " + status.RunningCampaigns + " campaign(s)";
document.getElementById("dispatchSummary").innerText =
"Pending " + status.Counts.pending + ", accepted " + status.Counts.accepted +
", uncertain " + status.Counts.uncertain + ", suppressed " +
status.Counts.suppressed + ". Next allowed attempt (UTC): " +
(status.NextAllowedAttemptUtc || "Now") + ".";
} catch (error) {
document.getElementById("dispatchMessage").innerText =
"Dispatch status failed: " + error.message;
}
}
function dispatchTick() {
if (!dispatchReady) return;
try {
writeHeartbeat();
if (dispatchProcess) {
if (dispatchProcess.Status === 0) return;
var output = dispatchProcess.StdOut.ReadAll();
var errors = dispatchProcess.StdErr.ReadAll();
var exitCode = dispatchProcess.ExitCode;
dispatchProcess = null;
document.getElementById("dispatchMessage").innerText =
(exitCode === 0 ? "" : "Dispatch error: ") + output + errors;
refreshStatus();
refreshCampaigns();
refreshDispatchStatus();
return;
}
var db = openDatabase().db;
var running = 0;
try {
var rows = db.Execute("SELECT COUNT(*) AS N FROM [Campaign] WHERE [Status]='running'");
running = Number(rows.Fields("N").Value);
rows.Close();
} finally { db.Close(); }
if (!running) return;
var fs = new ActiveXObject("Scripting.FileSystemObject");
var script = fs.BuildPath(projectRoot(), "tools\\dispatch-once.ps1");
var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " +
quoted(script) + " -ConfigPath " + quoted(fs.BuildPath(projectRoot(), "config.ini")) +
" -Send -SessionPid " + dispatchSessionPid +
" -SessionToken " + dispatchSessionToken;
dispatchProcess = new ActiveXObject("WScript.Shell").Exec(command);
} catch (error) {
document.getElementById("dispatchMessage").innerText =
"Dispatch tick failed: " + error.message;
}
}
function dispatchControl(action, campaignId, deliveryId) {
try {
var args = "-Action " + action;
if (campaignId) args += " -CampaignId " + campaignId;
if (deliveryId) args += " -DeliveryId " + deliveryId;
var result = runTemplateTool("dispatch-control.ps1", args);
document.getElementById("dispatchMessage").innerText = result;
refreshCampaigns();
refreshUncertain();
refreshDispatchStatus();
} catch (error) {
document.getElementById("dispatchMessage").innerText =
"Control failed: " + error.message;
}
}
function reviewOptOutMailbox() {
if (window.confirm("Have you checked the monitored reply mailbox and recorded all opt-outs?"))
dispatchControl("ReviewOptOut", 0);
}
function selectedDispatchCampaign() {
var id = document.getElementById("dispatchCampaign").value;
if (!/^[1-9][0-9]*$/.test(id)) throw new Error("Select an approved campaign");
return id;
}
function resumeSelectedCampaign() {
try {
var id = selectedDispatchCampaign();
if (window.confirm("Start sending campaign #" + id +
" to approved business contacts while this HTA stays open?"))
dispatchControl("Resume", id);
} catch (error) { document.getElementById("dispatchMessage").innerText = error.message; }
}
function testApprovedCampaign() {
try {
var id = selectedDispatchCampaign();
var fs = new ActiveXObject("Scripting.FileSystemObject");
var settings = readSettings(fs.BuildPath(projectRoot(), "config.ini"));
if (!settings.SMTP_TEST_TO) throw new Error("Set SMTP_TEST_TO in config.ini first");
if (!window.confirm("Send a test of approved campaign #" + id +
" to " + settings.SMTP_TEST_TO + " only?")) return;
document.getElementById("dispatchMessage").innerText =
runTemplateTool("send-approved-campaign-test.ps1", "-CampaignId " + id + " -Send");
} catch (error) {
document.getElementById("dispatchMessage").innerText =
"Approved campaign test failed: " + error.message;
}
}
function cancelSelectedCampaign() {
try {
var id = selectedDispatchCampaign();
if (window.confirm("Cancel all pending deliveries in campaign #" + id + "?"))
dispatchControl("Cancel", id);
} catch (error) { document.getElementById("dispatchMessage").innerText = error.message; }
}
function refreshUncertain() {
var db = null;
try {
db = openDatabase().db;
var rows = db.Execute("SELECT [Id],[CampaignId],[EmailKey],[AttemptCount] FROM [Delivery] WHERE [Status]='uncertain' ORDER BY [Id]");
var select = document.getElementById("uncertainDelivery");
var selectedDelivery = select.value;
while (select.options.length) select.remove(0);
while (!rows.EOF) {
var option = document.createElement("option");
option.value = String(rows.Fields("Id").Value);
option.text = "Delivery #" + option.value + " / campaign #" +
rows.Fields("CampaignId").Value + " / " + rows.Fields("EmailKey").Value +
" / attempts " + rows.Fields("AttemptCount").Value;
select.add(option);
rows.MoveNext();
}
rows.Close();
for (var i = 0; i < select.options.length; i++) {
if (select.options[i].value === selectedDelivery) select.selectedIndex = i;
}
} catch (error) {
document.getElementById("dispatchMessage").innerText =
"Uncertain list failed: " + error.message;
} finally {
if (db !== null && db.State === 1) db.Close();
}
}
function reconcileDelivery(action) {
var id = document.getElementById("uncertainDelivery").value;
if (!/^[1-9][0-9]*$/.test(id)) {
document.getElementById("dispatchMessage").innerText = "Select an uncertain delivery.";
return;
}
var prompt = action === "ResolveAccepted" ?
"Have you verified delivery #" + id + " was accepted by the provider?" :
"Have you checked that delivery #" + id + " was not sent and authorize one retry?";
if (window.confirm(prompt)) dispatchControl(action, 0, id);
}
function exportSelectedReport() {
try {
var id = document.getElementById("reportCampaign").value;
if (!/^[1-9][0-9]*$/.test(id)) throw new Error("Select a campaign to report");
document.getElementById("reportMessage").innerText =
runTemplateTool("export-report.ps1", "-CampaignId " + id);
} catch (error) {
document.getElementById("reportMessage").innerText =
"Report export failed: " + error.message;
}
}
</script>
</head>
<body onload="refreshStatus()">
<body onload="startup()" onunload="shutdownDispatch()">
<header><h1>MassMailer</h1><p>9 x 12 postcard marketing campaign workspace</p></header>
<main>
<div class="notice"><strong>Campaign sending is disabled in this build.</strong> The SMTP test sends one message only when you press its button.</div>
<div class="tabbar" role="tablist" aria-label="Application sections">
<button type="button" class="tab-button" data-tab="overview" role="tab" onclick="showTab('overview')">Overview</button>
<button type="button" class="tab-button" data-tab="smtp" role="tab" onclick="showTab('smtp')">SMTP</button>
<button type="button" class="tab-button" data-tab="contacts" role="tab" onclick="showTab('contacts')">Contacts</button>
<button type="button" class="tab-button" data-tab="templates" role="tab" onclick="showTab('templates')">Templates</button>
<button type="button" class="tab-button" data-tab="campaigns" role="tab" onclick="showTab('campaigns')">Campaigns</button>
<button type="button" class="tab-button" data-tab="sending" role="tab" onclick="showTab('sending')">Sending</button>
<button type="button" class="tab-button" data-tab="reports" role="tab" onclick="showTab('reports')">Reports</button>
</div>
<div class="tab-pane" data-tab="overview" role="tabpanel">
<div class="notice"><strong>Campaigns start only when you explicitly resume them.</strong> Sending stops when this window closes. Review replies and record opt-outs before resuming.</div>
<div class="cards">
<div class="card">Contacts<span class="number" id="contacts">—</span></div>
<div class="card">Suppressed<span class="number" id="suppressed">—</span></div>
@@ -491,18 +783,19 @@
<div><strong>Provider</strong><span id="provider">Checking…</span></div>
<div><strong>Send interval</strong><span id="interval">Checking…</span></div>
<div><strong>Internal test to</strong><span id="testTo">Checking…</span></div>
<div><strong>Send state</strong>Disabled</div>
<div><strong>Send state</strong><span id="sendState">Initializing…</span></div>
<button onclick="refreshStatus()">Refresh status</button>
<div id="error"></div>
</div>
<div class="panel">
</div>
<div class="panel tab-pane" data-tab="smtp" role="tabpanel">
<h2>SMTP setup and internal test</h2>
<p>The password entry opens a local console and stores a protected value in the MDB. The test sends one HTML message with an inline image to the configured test inbox.</p>
<button onclick="configurePassword()">Set or rotate SMTP password</button>
<button onclick="sendInternalTest()">Send one internal test</button>
<div id="transportMessage"></div>
</div>
<div class="panel">
<div class="panel tab-pane" data-tab="contacts" role="tabpanel">
<h2>Import business contacts</h2>
<p>UTF-8 CSV needs an <code>email</code> header. Optional headers: <code>business_name</code>, <code>source_reference</code>, and <code>acquired_at</code> (YYYY-MM-DD). Every accepted row needs a source reference.</p>
<label class="field">CSV file <input id="csvPath" type="file" accept=".csv" /></label>
@@ -515,7 +808,7 @@
<table><thead><tr><th>CSV row</th><th>Email</th><th>Business</th><th>Source</th><th>Result</th></tr></thead>
<tbody id="previewBody"></tbody></table>
</div>
<div class="panel">
<div class="panel tab-pane" data-tab="templates" role="tabpanel">
<h2>HTML templates</h2>
<p>Choose a well-formed HTML file inside the configured template folder. Images must use relative PNG, JPEG, or GIF paths. Imports create a new version and copy image bytes into the database.</p>
<label class="field">Template file <input id="templatePath" type="file" accept=".html,.htm" /></label>
@@ -525,9 +818,9 @@
<div id="templateMessage"></div>
<iframe id="templatePreview" APPLICATION="no" title="Template preview"></iframe>
</div>
<div class="panel">
<div class="panel tab-pane" data-tab="campaigns" role="tabpanel">
<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 dispatch controls are built.</p>
<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>
<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">Template version <select id="campaignTemplateVersion" onchange="invalidateCampaignReview()"></select></label>
@@ -540,7 +833,23 @@
<div id="campaignMessage"></div>
<table><thead><tr><th>ID</th><th>Campaign</th><th>Status</th></tr></thead><tbody id="campaignBody"></tbody></table>
</div>
<div class="panel">
<div class="panel tab-pane" data-tab="sending" role="tabpanel">
<h2>Open-window sending</h2>
<p>Check the reply mailbox daily. A send starts only while this HTA is open, after you explicitly resume an approved campaign. The configured interval applies across campaigns. Uncertain results pause sending for review.</p>
<button id="reviewOptOut" onclick="reviewOptOutMailbox()" disabled="disabled">I reviewed replies and recorded opt-outs</button>
<label class="field">Approved campaign <select id="dispatchCampaign"></select></label>
<button onclick="testApprovedCampaign()">Send selected campaign test to internal inbox</button>
<button id="resumeCampaign" onclick="resumeSelectedCampaign()" disabled="disabled">Resume selected campaign</button>
<button id="pauseAll" onclick="dispatchControl('PauseAll',0)" disabled="disabled">Pause all</button>
<button id="cancelCampaign" onclick="cancelSelectedCampaign()" disabled="disabled">Cancel selected campaign</button>
<div id="dispatchSummary"></div>
<div id="dispatchMessage"></div>
<button id="retryDispatch" onclick="initializeDispatch()">Retry dispatcher initialization</button>
<label class="field">Uncertain delivery <select id="uncertainDelivery"></select></label>
<button onclick="reconcileDelivery('ResolveAccepted')">Mark verified accepted</button>
<button onclick="reconcileDelivery('AuthorizeRetry')">Authorize one retry</button>
</div>
<div class="panel tab-pane" data-tab="contacts" role="tabpanel">
<h2>Suppress an address</h2>
<p>Add unsubscribe replies here before the next send. Suppression applies across all campaigns.</p>
<label class="field">Email address <input id="suppressEmail" type="text" maxlength="254" /></label>
@@ -549,6 +858,13 @@
<button onclick="addSuppression()">Suppress address</button>
<div id="suppressMessage"></div>
</div>
<div class="panel tab-pane" data-tab="reports" role="tabpanel">
<h2>Campaign reports</h2>
<p>Export delivery statuses and attempt history to CSV files in the configured report folder. SMTP acceptance means the server accepted a message; it does not confirm inbox delivery.</p>
<label class="field">Campaign <select id="reportCampaign"></select></label>
<button onclick="exportSelectedReport()">Export campaign CSV reports</button>
<div id="reportMessage"></div>
</div>
</main>
</body>
</html>

+ 9
- 5
README.md Parādīt failu

@@ -1,6 +1,6 @@
# MassMailer

Windows HTA application under construction for individual marketing emails for a 9 x 12 postcard business.
Windows HTA application for individual marketing emails for a 9 x 12 postcard business.

## Current build

@@ -9,10 +9,13 @@ Windows HTA application under construction for individual marketing emails for a
- Imports UTF-8 contacts from a previewed CSV and records global suppressions.
- Imports constrained, well-formed HTML templates from `TEMPLATE_ROOT`, snapshots local PNG/JPEG/GIF images into the MDB, and previews the stored version in an untrusted HTA frame.
- Stores the SMTP password as a Windows user-protected value in the MDB through an interactive local setup action.
- Offers an explicit, single-message SMTP transport test to the configured `SMTP_TEST_TO` address. Campaign sending is not implemented yet.
- Offers an explicit, single-message SMTP transport test to the configured `SMTP_TEST_TO` address.
- Reviews a campaign against unsuppressed contacts, adds an advertisement, postal, and reply opt-out footer, tests the reviewed message only to the internal inbox, and records approval in a paused state.
- Uses tabs for Overview, SMTP, Contacts, Templates, Campaigns, Sending, and Reports. Contacts includes import and global suppression.
- Sends approved campaigns only while the HTA is open, with a configurable account-wide gap between attempts. It pauses on restart and uncertain outcomes, rechecks suppression before each send, and requires a fresh mailbox opt-out review before resuming.
- Sends a test of an approved campaign to the internal inbox without advancing delivery counts, and exports delivery and attempt CSV reports to `REPORT_DIR`.

The app sends no marketing messages in this build. Closing the HTA stops all in-app activity.
Live marketing dispatch has passed disposable-database pickup tests. It still needs an owner-run internal pilot and inbox review before use with business contacts. Closing the HTA stops new send attempts; an SMTP attempt already in progress may finish.

## Setup on this PC

@@ -23,12 +26,13 @@ The app sends no marketing messages in this build. Closing the HTA stops all in-
5. Enter opt-out replies in the suppression form. Press **Set or rotate SMTP password** to enter the password locally, then press **Send one internal test**. The owner reports that this authenticated SMTP test worked on this PC; detailed received-image rendering has not been separately recorded.
6. Put template files under `TEMPLATE_ROOT` and import them from the HTA. The accepted HTML subset and example are in [templates/README.md](templates/README.md). Imported versions are immutable snapshots. The template preview shows stored content, while the campaign preview adds the full footer.
7. Fill in `BUSINESS_POSTAL_ADDRESS` in local `config.ini`. Enter a campaign name and subject, select an imported template, and press **Review message and recipients**. After checking the full preview, send the campaign-specific test to `SMTP_TEST_TO` and confirm it arrived. Approval records paused deliveries; it does not start sending.
8. In **Sending**, send an optional test of the approved snapshot to `SMTP_TEST_TO`. Check the monitored reply mailbox, record opt-outs under **Contacts**, then mark the mailbox reviewed and explicitly resume the selected campaign. Keep the HTA open while sending. Use **Pause all** to stop new attempts and **Reports** to export delivery and attempt histories.

Local `config.ini`, MDB files, logs, backups, and temporary test data are ignored by Git.

## Verification

Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-contact-flow.ps1` for a disposable MDB integration check. It covers database initialization and preflight, protected password save/load/rotation using synthetic values, local MIME pickup with an inline image, contact import, duplicate rejection, and suppression. Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-template-flow.ps1` for template import, snapshot, preview, and unsafe-input checks. Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-campaign-flow.ps1` for recipient review, stale-review rejection, internal-only campaign MIME, and paused approval. `C:\Windows\SysWOW64\cscript.exe //nologo //E:JScript tools\test-contacts.js` covers CSV parsing and validation. `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-hta-syntax.ps1` checks the HTA script. `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-smtp-capabilities.ps1` checks the configured server's STARTTLS handshake without authenticating or sending mail.
Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-contact-flow.ps1` for disposable-MDB setup, protected secret, contact, suppression, and MIME pickup checks. `tools\test-template-flow.ps1` checks template import and preview. `tools\test-campaign-flow.ps1` checks review, tests before and after approval, and paused approval. `tools\test-dispatch-flow.ps1` checks session guard, pacing, restart, suppression, uncertain outcomes, reconciliation, and reports with local pickup files. `tools\test-hta-syntax.ps1` checks JScript syntax; `tools\test-hta-startup.ps1` opens a hidden HTA, checks the live session guard, and closes only that test instance. `tools\test-smtp-capabilities.ps1` checks the server STARTTLS handshake without sending mail.

## Planning documents

@@ -39,4 +43,4 @@ Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-contact-
- [Example SMTP configuration without a password](config.example.ini)
- [Access provider probe for 32-bit and 64-bit hosts](tools/probe-mdb-providers.vbs)

System-wide settings belong in the INI; campaign settings, templates, contacts, queue state, and protected secrets belong in the Access MDB. Live campaign dispatch, pacing, recovery, and reporting are still under development.
System-wide settings belong in the INI; campaign settings, templates, contacts, queue state, and protected secrets belong in the Access MDB. Backups and a restore drill remain to be implemented.

+ 2
- 0
config.example.ini Parādīt failu

@@ -23,6 +23,8 @@ TEMPLATE_IMAGE_MAX_BYTES=2097152
TEMPLATE_MESSAGE_MAX_BYTES=10485760
OPT_OUT_REVIEW_HOURS=24
BUSINESS_POSTAL_ADDRESS=
REPORT_DIR=reports
BACKUP_DIR=backups

# QUEUE_INTERVAL is the minimum number of seconds between attempts across
# all campaigns using this SMTP account. TLS=on plus STARTTLS=on means the


+ 1
- 1
templates/README.md Parādīt failu

@@ -14,4 +14,4 @@ Example:
</body></html>
```

Create the `images/postcard.png` file beside the source HTML before importing. The template preview shows imported content and images. The campaign review preview adds the sender, postal address, ad identification, and opt-out footer. Campaign sending remains disabled.
Create the `images/postcard.png` file beside the source HTML before importing. The template preview shows imported content and images. The campaign review preview adds the sender, postal address, ad identification, and opt-out footer. Approved campaigns stay paused until explicitly resumed in the Sending tab.

+ 1
- 1
tools/common.vbs Parādīt failu

@@ -67,7 +67,7 @@ Function IsAllowedKey(key)
"SEND_MODE", "DISPLAY_TIME_ZONE", "TEMPLATE_ROOT", _
"TEMPLATE_IMAGE_MODE", "TEMPLATE_IMAGE_TYPES", _
"TEMPLATE_IMAGE_MAX_BYTES", "TEMPLATE_MESSAGE_MAX_BYTES", _
"OPT_OUT_REVIEW_HOURS", "BUSINESS_POSTAL_ADDRESS")
"OPT_OUT_REVIEW_HOURS", "BUSINESS_POSTAL_ADDRESS", "REPORT_DIR", "BACKUP_DIR")
If key = name Then IsAllowedKey = True: Exit Function
Next
End Function


+ 191
- 0
tools/dispatch-common.ps1 Parādīt failu

@@ -0,0 +1,191 @@
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'campaign-common.ps1')

function Enter-DispatchLock {
param([int]$WaitMilliseconds = 0)
$folder = Join-Path (Split-Path -Parent $PSScriptRoot) 'temp'
if (-not (Test-Path -LiteralPath $folder -PathType Container)) {
$null = New-Item -ItemType Directory -Path $folder -Force
}
$path = Join-Path $folder 'dispatch.lock'
$deadline = [DateTime]::UtcNow.AddMilliseconds($WaitMilliseconds)
while ($true) {
try {
return [System.IO.File]::Open($path, [System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
}
catch [System.IO.IOException] {
if ([DateTime]::UtcNow -ge $deadline) {
throw 'Another dispatch or campaign control action is still running'
}
Start-Sleep -Milliseconds 250
}
}
}

function Get-QueueInterval {
param($Context)
$interval = 0
if (-not [int]::TryParse($Context.Settings['QUEUE_INTERVAL'], [ref]$interval) -or
$interval -lt 60 -or $interval -gt 86400) {
throw 'QUEUE_INTERVAL must be 60-86400 seconds'
}
return $interval
}

function Get-ReviewHours {
param($Context)
$hours = 0
if (-not [int]::TryParse($Context.Settings['OPT_OUT_REVIEW_HOURS'], [ref]$hours) -or
$hours -lt 1 -or $hours -gt 168) {
throw 'OPT_OUT_REVIEW_HOURS must be 1-168'
}
return $hours
}

function Get-OperationalState {
param($Connection, $Transaction = $null)
$command = New-DbCommand $Connection 'SELECT [GlobalPause],[LastOptOutReviewAtUtc] FROM [OperationalState] WHERE [Id]=1' $Transaction
$reader = $command.ExecuteReader()
try {
if (-not $reader.Read()) { throw 'OperationalState row is missing' }
$reviewed = if ($reader.IsDBNull(1)) { $null } else { [DateTime]$reader.GetValue(1) }
return @{ Paused = [bool]$reader.GetValue(0); Reviewed = $reviewed }
}
finally { $reader.Dispose() }
}

function Assert-OptOutReviewCurrent {
param($Context, $State)
$hours = Get-ReviewHours $Context
if ($null -eq $State.Reviewed -or $State.Reviewed -lt [DateTime]::UtcNow.AddHours(-$hours)) {
throw 'Review the reply mailbox and record opt-outs before sending'
}
}

function Get-DispatchClock {
param($Connection, [string]$SenderKey, [int]$Interval, $Transaction = $null)
$command = New-DbCommand $Connection 'SELECT [LastAttemptAtUtc],[NextAllowedAttemptUtc] FROM [DispatchState] WHERE [SenderKey]=?' $Transaction
Add-TextParameter $command 'sender' 254 $SenderKey
$reader = $command.ExecuteReader()
try {
if (-not $reader.Read()) { return @{ Exists = $false; Last = $null; Next = $null } }
$last = if ($reader.IsDBNull(0)) { $null } else { [DateTime]$reader.GetValue(0) }
$next = if ($reader.IsDBNull(1)) { $null } else { [DateTime]$reader.GetValue(1) }
}
finally { $reader.Dispose() }
if ($null -ne $last) {
$newGap = $last.AddSeconds($Interval)
if ($null -eq $next -or $newGap -gt $next) { $next = $newGap }
}
return @{ Exists = $true; Last = $last; Next = $next }
}

function Assert-OpenHtaSession {
param([int]$HtaPid, [string]$Token)
if ($HtaPid -lt 1 -or $Token -notmatch '^[0-9a-f]{32}$') { throw 'Missing HTA session token' }
$path = Join-Path (Split-Path -Parent $PSScriptRoot) "temp\session-$HtaPid.txt"
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'HTA session is closed' }
$file = Get-Item -LiteralPath $path
if ($file.LastWriteTimeUtc -lt [DateTime]::UtcNow.AddSeconds(-20) -or
([System.IO.File]::ReadAllText($path)).Trim() -ne $Token) {
throw 'HTA session heartbeat is stale'
}
$process = Get-WmiObject Win32_Process -Filter "ProcessId=$HtaPid" -ErrorAction Stop
if (-not $process -or $process.Name -ne 'mshta.exe' -or
$process.CommandLine -notmatch '(?i)MassMailer\.hta') {
throw 'HTA process is not running'
}
}

function Get-DeliverySnapshot {
param($Connection, [int]$DeliveryId)
$sql = 'SELECT d.[EmailKey],c.[EmailOriginal],m.[FromSnapshot],m.[ReplyToSnapshot],m.[PostalAddressSnapshot],m.[Subject],m.[TemplateVersionId],m.[Status] FROM ([Delivery] AS d INNER JOIN [Contact] AS c ON d.[ContactId]=c.[Id]) INNER JOIN [Campaign] AS m ON d.[CampaignId]=m.[Id] WHERE d.[Id]=?'
$command = New-DbCommand $Connection $sql
$null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$command.Parameters[0].Value = $DeliveryId
$reader = $command.ExecuteReader()
try {
if (-not $reader.Read()) { throw 'Reserved delivery is missing' }
$emailKey = [string]$reader.GetValue(0)
$address = [string]$reader.GetValue(1)
if ($emailKey -ne $address.ToLowerInvariant()) { throw 'Contact email changed after approval' }
return @{ EmailKey = $emailKey; Email = $address; From = [string]$reader.GetValue(2);
ReplyTo = [string]$reader.GetValue(3); Postal = [string]$reader.GetValue(4);
Subject = [string]$reader.GetValue(5); TemplateId = [int]$reader.GetValue(6);
CampaignStatus = [string]$reader.GetValue(7) }
}
finally { $reader.Dispose() }
}

function Get-ApprovedCampaignSnapshot {
param($Connection, [int]$CampaignId)
$command = New-DbCommand $Connection 'SELECT [FromSnapshot],[ReplyToSnapshot],[PostalAddressSnapshot],[Subject],[TemplateVersionId],[Status] FROM [Campaign] WHERE [Id]=?'
$null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$command.Parameters[0].Value = $CampaignId
$reader = $command.ExecuteReader()
try {
if (-not $reader.Read()) { throw 'Approved campaign was not found' }
$status = [string]$reader.GetValue(5)
if ($status -notin @('approved_paused','running')) {
throw 'Campaign is not approved and active'
}
return @{ From = [string]$reader.GetValue(0); ReplyTo = [string]$reader.GetValue(1);
Postal = [string]$reader.GetValue(2); Subject = [string]$reader.GetValue(3);
TemplateId = [int]$reader.GetValue(4) }
}
finally { $reader.Dispose() }
}

function Test-Suppressed {
param($Connection, [string]$EmailKey, $Transaction = $null)
$command = New-DbCommand $Connection 'SELECT COUNT(*) FROM [Suppression] WHERE [EmailKey]=?' $Transaction
Add-TextParameter $command 'key' 254 $EmailKey
return [int]$command.ExecuteScalar() -gt 0
}

function Complete-DeliveryAttempt {
param($Connection, [int]$DeliveryId, [int]$AttemptId,
[string]$Outcome, [string]$Status, [string]$ErrorCode = '')
$transaction = $Connection.BeginTransaction()
try {
$attempt = New-DbCommand $Connection 'UPDATE [Attempt] SET [EndedAtUtc]=?,[Outcome]=?,[SanitizedError]=? WHERE [Id]=? AND [Outcome]=?' $transaction
$null = $attempt.Parameters.Add('ended', [System.Data.OleDb.OleDbType]::Date)
$attempt.Parameters[0].Value = [DateTime]::UtcNow
Add-TextParameter $attempt 'outcome' 30 $Outcome
Add-TextParameter $attempt 'error' 255 $ErrorCode
$null = $attempt.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$attempt.Parameters[3].Value = $AttemptId
Add-TextParameter $attempt 'started' 30 'started'
if ($attempt.ExecuteNonQuery() -ne 1) { throw 'Attempt was already finalized' }
$delivery = New-DbCommand $Connection 'UPDATE [Delivery] SET [Status]=?,[LastErrorCode]=? WHERE [Id]=? AND [Status]=?' $transaction
Add-TextParameter $delivery 'status' 30 $Status
Add-TextParameter $delivery 'error' 100 $ErrorCode
$null = $delivery.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$delivery.Parameters[2].Value = $DeliveryId
Add-TextParameter $delivery 'inflight' 30 'in_flight'
if ($delivery.ExecuteNonQuery() -ne 1) { throw 'Delivery was already finalized' }
$transaction.Commit()
}
catch { $transaction.Rollback(); throw }
}

function Pause-DispatchForReview {
param($Connection, [int]$DeliveryId)
$transaction = $Connection.BeginTransaction()
try {
$command = New-DbCommand $Connection "UPDATE [Campaign] SET [Status]='approved_paused' WHERE [Status]='running'" $transaction
$null = $command.ExecuteNonQuery()
$command = New-DbCommand $Connection 'UPDATE [OperationalState] SET [GlobalPause]=TRUE WHERE [Id]=1' $transaction
$null = $command.ExecuteNonQuery()
Write-CampaignAudit $Connection 'dispatch_uncertain' ([string]$DeliveryId) 'Provider outcome requires manual review' $transaction
$transaction.Commit()
}
catch { $transaction.Rollback(); throw }
}

function Complete-FinishedCampaigns {
param($Connection)
$command = New-DbCommand $Connection "UPDATE [Campaign] SET [Status]='completed' WHERE [Status]='running' AND [Id] NOT IN (SELECT [CampaignId] FROM [Delivery] WHERE [Status] IN ('pending','in_flight','uncertain'))"
$null = $command.ExecuteNonQuery()
}

+ 129
- 0
tools/dispatch-control.ps1 Parādīt failu

@@ -0,0 +1,129 @@
param(
[Parameter(Mandatory = $true)][ValidateSet('Recover','ReviewOptOut','Resume','PauseAll','Cancel','ResolveAccepted','AuthorizeRetry','Status')][string]$Action,
[int]$CampaignId,
[int]$DeliveryId,
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini')
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'dispatch-common.ps1')
$context = Get-MailContext $ConfigPath
$lock = Enter-DispatchLock -WaitMilliseconds 30000
$db = $null
try {
$db = Open-MailDatabase $context
if ($Action -eq 'Status') {
$state = Get-OperationalState $db
$command = New-DbCommand $db "SELECT COUNT(*) FROM [Campaign] WHERE [Status]='running'"
$running = [int]$command.ExecuteScalar()
$counts = @{}
foreach ($status in @('pending','in_flight','accepted','uncertain','suppressed','canceled')) {
$command = New-DbCommand $db 'SELECT COUNT(*) FROM [Delivery] WHERE [Status]=?'
Add-TextParameter $command 'status' 30 $status
$counts[$status] = [int]$command.ExecuteScalar()
}
$clock = Get-DispatchClock $db $context.Settings['SMTP_USER'].ToLowerInvariant() (Get-QueueInterval $context)
$nextText = if ($null -eq $clock.Next) { $null } else { $clock.Next.ToString('o') }
$reviewText = if ($null -eq $state.Reviewed) { $null } else { $state.Reviewed.ToString('o') }
[pscustomobject]@{ Paused = $state.Paused; RunningCampaigns = $running;
Counts = $counts; NextAllowedAttemptUtc = $nextText;
LastOptOutReviewUtc = $reviewText } | ConvertTo-Json -Compress -Depth 4
return
}
$transaction = $db.BeginTransaction()
try {
if ($Action -eq 'Recover') {
$command = New-DbCommand $db "UPDATE [Campaign] SET [Status]='approved_paused' WHERE [Status]='running'" $transaction
$null = $command.ExecuteNonQuery()
$command = New-DbCommand $db 'UPDATE [OperationalState] SET [GlobalPause]=TRUE WHERE [Id]=1' $transaction
$null = $command.ExecuteNonQuery()
$command = New-DbCommand $db "UPDATE [Attempt] SET [Outcome]='uncertain',[EndedAtUtc]=?,[SanitizedError]='HTA session ended before provider outcome was recorded' WHERE [Outcome]='started'" $transaction
$null = $command.Parameters.Add('ended', [System.Data.OleDb.OleDbType]::Date)
$command.Parameters[0].Value = [DateTime]::UtcNow
$null = $command.ExecuteNonQuery()
$command = New-DbCommand $db "UPDATE [Delivery] SET [Status]='uncertain',[LastErrorCode]='session_interrupted' WHERE [Status]='in_flight'" $transaction
$null = $command.ExecuteNonQuery()
Write-CampaignAudit $db 'dispatcher_recovered' 'session' 'Open-window dispatcher started paused' $transaction
$message = 'Recovered previous session; all campaigns are paused.'
}
elseif ($Action -eq 'ReviewOptOut') {
$command = New-DbCommand $db 'UPDATE [OperationalState] SET [LastOptOutReviewAtUtc]=? WHERE [Id]=1' $transaction
$null = $command.Parameters.Add('reviewed', [System.Data.OleDb.OleDbType]::Date)
$command.Parameters[0].Value = [DateTime]::UtcNow
$null = $command.ExecuteNonQuery()
Write-CampaignAudit $db 'opt_out_reviewed' 'mailbox' 'Operator confirmed reply mailbox review' $transaction
$message = 'Reply mailbox review recorded.'
}
elseif ($Action -eq 'PauseAll') {
$command = New-DbCommand $db "UPDATE [Campaign] SET [Status]='approved_paused' WHERE [Status]='running'" $transaction
$null = $command.ExecuteNonQuery()
$command = New-DbCommand $db 'UPDATE [OperationalState] SET [GlobalPause]=TRUE WHERE [Id]=1' $transaction
$null = $command.ExecuteNonQuery()
Write-CampaignAudit $db 'dispatch_paused' 'all' 'Operator paused all campaigns' $transaction
$message = 'All campaigns paused. A current in-flight attempt may finish.'
}
elseif ($Action -eq 'Resume') {
if ($CampaignId -lt 1) { throw 'Select a campaign to resume' }
$null = Get-CampaignIdentity $context
$null = Get-QueueInterval $context
$state = Get-OperationalState $db $transaction
Assert-OptOutReviewCurrent $context $state
$command = New-DbCommand $db "SELECT COUNT(*) FROM [Delivery] WHERE [Status]='uncertain'" $transaction
if ([int]$command.ExecuteScalar() -gt 0) {
throw 'Resolve uncertain deliveries before resuming any campaign'
}
$command = New-DbCommand $db "SELECT COUNT(*) FROM [Delivery] WHERE [CampaignId]=? AND [Status]='pending'" $transaction
$null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$command.Parameters[0].Value = $CampaignId
if ([int]$command.ExecuteScalar() -lt 1) { throw 'Campaign has no pending deliveries' }
$command = New-DbCommand $db "UPDATE [Campaign] SET [Status]='running' WHERE [Id]=? AND [Status]='approved_paused'" $transaction
$null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$command.Parameters[0].Value = $CampaignId
if ($command.ExecuteNonQuery() -ne 1) { throw 'Campaign is not paused or does not exist' }
$command = New-DbCommand $db 'UPDATE [OperationalState] SET [GlobalPause]=FALSE WHERE [Id]=1' $transaction
$null = $command.ExecuteNonQuery()
Write-CampaignAudit $db 'campaign_resumed' ([string]$CampaignId) 'Operator explicitly resumed campaign' $transaction
$message = "Campaign $CampaignId resumed. Dispatch runs only while this HTA is open."
}
elseif ($Action -eq 'Cancel') {
if ($CampaignId -lt 1) { throw 'Select a campaign to cancel' }
$command = New-DbCommand $db "UPDATE [Campaign] SET [Status]='canceled' WHERE [Id]=? AND [Status] IN ('approved_paused','running')" $transaction
$null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$command.Parameters[0].Value = $CampaignId
if ($command.ExecuteNonQuery() -ne 1) { throw 'Campaign is not active or does not exist' }
$command = New-DbCommand $db "UPDATE [Delivery] SET [Status]='canceled' WHERE [CampaignId]=? AND [Status]='pending'" $transaction
$null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$command.Parameters[0].Value = $CampaignId
$null = $command.ExecuteNonQuery()
Write-CampaignAudit $db 'campaign_canceled' ([string]$CampaignId) 'Operator canceled pending deliveries' $transaction
$message = "Campaign $CampaignId canceled. A current in-flight attempt may finish."
}
elseif ($Action -in @('ResolveAccepted','AuthorizeRetry')) {
if ($DeliveryId -lt 1) { throw 'Select an uncertain delivery' }
$state = Get-OperationalState $db $transaction
if (-not $state.Paused) { throw 'Pause all campaigns before resolving an uncertain delivery' }
if ($Action -eq 'AuthorizeRetry') {
$command = New-DbCommand $db "UPDATE [Delivery] SET [Status]='pending',[LastErrorCode]='manual_retry_authorized' WHERE [Id]=? AND [Status]='uncertain' AND [AttemptCount]<3" $transaction
}
else {
$command = New-DbCommand $db "UPDATE [Delivery] SET [Status]='accepted',[LastErrorCode]='operator_verified' WHERE [Id]=? AND [Status]='uncertain'" $transaction
}
$null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$command.Parameters[0].Value = $DeliveryId
if ($command.ExecuteNonQuery() -ne 1) {
throw 'Delivery is not uncertain, or the three-attempt manual retry limit was reached'
}
$auditAction = if ($Action -eq 'AuthorizeRetry') { 'retry_authorized' } else { 'delivery_verified_accepted' }
Write-CampaignAudit $db $auditAction ([string]$DeliveryId) 'Operator reconciled uncertain provider result' $transaction
$message = if ($Action -eq 'AuthorizeRetry') {
"Delivery $DeliveryId is pending for one explicitly authorized retry. Resume the campaign when ready."
} else { "Delivery $DeliveryId marked accepted after operator verification." }
}
$transaction.Commit()
Write-Output $message
}
catch { $transaction.Rollback(); throw }
}
finally {
if ($db) { $db.Dispose() }
$lock.Dispose()
}

+ 192
- 0
tools/dispatch-once.ps1 Parādīt failu

@@ -0,0 +1,192 @@
param(
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'),
[switch]$Send,
[string]$PickupDirectory,
[int]$SessionPid,
[string]$SessionToken,
[string]$TestNowUtc,
[switch]$TestSimulateUnknownAfterSend
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'dispatch-common.ps1')
if ([bool]$Send -eq [bool]$PickupDirectory) { throw 'Choose exactly one mode: -Send or -PickupDirectory' }
if ($Send -and $TestNowUtc) { throw 'TestNowUtc is only available in pickup mode' }
if ($Send -and $TestSimulateUnknownAfterSend) { throw 'Failure injection is only available in pickup mode' }
if ($Send) { Assert-OpenHtaSession $SessionPid $SessionToken }
elseif (-not (Test-Path -LiteralPath $PickupDirectory -PathType Container)) {
throw 'Pickup directory does not exist'
}
$now = if ($TestNowUtc) {
[DateTime]::Parse($TestNowUtc, [cultureinfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::AdjustToUniversal)
} else { [DateTime]::UtcNow }
$context = Get-MailContext $ConfigPath
$settings = $context.Settings
$interval = Get-QueueInterval $context
if ($settings['SMTP_TLS'] -ne 'on' -or $settings['SMTP_STARTTLS'] -ne 'on') {
throw 'SMTP_TLS and SMTP_STARTTLS must both be on'
}
$port = 0
if (-not [int]::TryParse($settings['SMTP_PORT'], [ref]$port) -or $port -lt 1 -or $port -gt 65535) {
throw 'Invalid SMTP_PORT'
}
$sender = $settings['SMTP_USER'].ToLowerInvariant()
$lock = Enter-DispatchLock
$db = $null
$password = $null
$client = $null
$message = $null
$reservedDelivery = 0
$reservedAttempt = 0
$sendStarted = $false
try {
$db = Open-MailDatabase $context
$state = Get-OperationalState $db
if ($state.Paused) { Write-Output 'Dispatch paused'; return }
Assert-OptOutReviewCurrent $context $state
$clock = Get-DispatchClock $db $sender $interval
if ($null -ne $clock.Next -and $now -lt $clock.Next) {
Write-Output ('Next attempt allowed at ' + $clock.Next.ToString('o'))
return
}
if ($Send) { $password = Load-SmtpPassword $context }
$transaction = $db.BeginTransaction()
try {
while ($true) {
$sql = "SELECT TOP 1 d.[Id],d.[EmailKey] FROM [Delivery] AS d INNER JOIN [Campaign] AS m ON d.[CampaignId]=m.[Id] WHERE d.[Status]='pending' AND m.[Status]='running' AND m.[StartAtUtc]<=? ORDER BY d.[Id]"
$pick = New-DbCommand $db $sql $transaction
$null = $pick.Parameters.Add('now', [System.Data.OleDb.OleDbType]::Date)
$pick.Parameters[0].Value = $now
$reader = $pick.ExecuteReader()
try {
if (-not $reader.Read()) { $candidate = $null }
else { $candidate = @{ Id = [int]$reader.GetValue(0); Key = [string]$reader.GetValue(1) } }
}
finally { $reader.Dispose() }
if ($null -eq $candidate) { break }
if (-not (Test-Suppressed $db $candidate.Key $transaction)) { break }
$skip = New-DbCommand $db "UPDATE [Delivery] SET [Status]='suppressed' WHERE [Id]=? AND [Status]='pending'" $transaction
$null = $skip.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$skip.Parameters[0].Value = $candidate.Id
$null = $skip.ExecuteNonQuery()
}
if ($null -eq $candidate) {
$transaction.Commit()
Complete-FinishedCampaigns $db
Write-Output 'No pending delivery is due'
return
}
$delivery = New-DbCommand $db "UPDATE [Delivery] SET [Status]='in_flight',[AttemptCount]=[AttemptCount]+1,[LastAttemptAtUtc]=? WHERE [Id]=? AND [Status]='pending'" $transaction
$null = $delivery.Parameters.Add('now', [System.Data.OleDb.OleDbType]::Date)
$delivery.Parameters[0].Value = $now
$null = $delivery.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$delivery.Parameters[1].Value = $candidate.Id
if ($delivery.ExecuteNonQuery() -ne 1) { throw 'Delivery was already reserved' }
$attempt = New-DbCommand $db 'INSERT INTO [Attempt] ([DeliveryId],[StartedAtUtc],[Outcome]) VALUES (?,?,?)' $transaction
$null = $attempt.Parameters.Add('delivery', [System.Data.OleDb.OleDbType]::Integer)
$attempt.Parameters[0].Value = $candidate.Id
$null = $attempt.Parameters.Add('now', [System.Data.OleDb.OleDbType]::Date)
$attempt.Parameters[1].Value = $now
Add-TextParameter $attempt 'outcome' 30 'started'
$null = $attempt.ExecuteNonQuery()
$identity = New-DbCommand $db 'SELECT @@IDENTITY' $transaction
$reservedAttempt = [int]$identity.ExecuteScalar()
$next = $now.AddSeconds($interval)
if ($clock.Exists) {
$pace = New-DbCommand $db 'UPDATE [DispatchState] SET [LastAttemptAtUtc]=?,[NextAllowedAttemptUtc]=? WHERE [SenderKey]=?' $transaction
$null = $pace.Parameters.Add('last', [System.Data.OleDb.OleDbType]::Date)
$pace.Parameters[0].Value = $now
$null = $pace.Parameters.Add('next', [System.Data.OleDb.OleDbType]::Date)
$pace.Parameters[1].Value = $next
Add-TextParameter $pace 'sender' 254 $sender
}
else {
$pace = New-DbCommand $db 'INSERT INTO [DispatchState] ([SenderKey],[LastAttemptAtUtc],[NextAllowedAttemptUtc]) VALUES (?,?,?)' $transaction
Add-TextParameter $pace 'sender' 254 $sender
$null = $pace.Parameters.Add('last', [System.Data.OleDb.OleDbType]::Date)
$pace.Parameters[1].Value = $now
$null = $pace.Parameters.Add('next', [System.Data.OleDb.OleDbType]::Date)
$pace.Parameters[2].Value = $next
}
$null = $pace.ExecuteNonQuery()
$reservedDelivery = $candidate.Id
$transaction.Commit()
}
catch { $transaction.Rollback(); throw }

try {
if ($Send) { Assert-OpenHtaSession $SessionPid $SessionToken }
$snapshot = Get-DeliverySnapshot $db $reservedDelivery
if ($snapshot.CampaignStatus -ne 'running') { throw 'Campaign was paused or canceled before send' }
if (Test-Suppressed $db $snapshot.EmailKey) {
Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'skipped' 'suppressed' 'suppressed_before_send'
Write-Output "Delivery $reservedDelivery skipped because it is suppressed"
return
}
$template = Get-TemplateSnapshot $db $snapshot.TemplateId
$html = Add-CampaignFooter $template.Document @{
From = $snapshot.From; ReplyTo = $snapshot.ReplyTo; Postal = $snapshot.Postal }
$message = New-Object System.Net.Mail.MailMessage
$message.From = New-Object System.Net.Mail.MailAddress($snapshot.From)
$null = $message.ReplyToList.Add($snapshot.ReplyTo)
$null = $message.To.Add($snapshot.Email)
$message.Subject = $snapshot.Subject
$message.SubjectEncoding = [System.Text.Encoding]::UTF8
$view = [System.Net.Mail.AlternateView]::CreateAlternateViewFromString(
$html, [System.Text.Encoding]::UTF8, 'text/html')
foreach ($image in $template.Images) {
$stream = New-Object System.IO.MemoryStream -ArgumentList (,([byte[]]$image.Bytes))
$resource = New-Object System.Net.Mail.LinkedResource($stream, $image.Type)
$resource.ContentId = $image.Cid
$resource.TransferEncoding = [System.Net.Mime.TransferEncoding]::Base64
$null = $view.LinkedResources.Add($resource)
}
$null = $message.AlternateViews.Add($view)
$client = New-Object System.Net.Mail.SmtpClient($settings['SMTP_HOST'], $port)
$client.Timeout = 20000
if ($Send) {
$client.UseDefaultCredentials = $false
$client.Credentials = New-Object System.Net.NetworkCredential -ArgumentList @($settings['SMTP_USER'], $password)
$client.EnableSsl = $true
Assert-OpenHtaSession $SessionPid $SessionToken
if ((Get-OperationalState $db).Paused -or (Test-Suppressed $db $snapshot.EmailKey)) {
throw 'Campaign paused or recipient suppressed before SMTP call'
}
}
else {
$client.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::SpecifiedPickupDirectory
$client.PickupDirectoryLocation = [System.IO.Path]::GetFullPath($PickupDirectory)
}
$sendStarted = $true
$client.Send($message)
if ($TestSimulateUnknownAfterSend) { throw 'Injected loss of provider acknowledgement' }
Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'accepted' 'accepted'
Complete-FinishedCampaigns $db
Write-Output "SMTP accepted delivery $reservedDelivery. Next slot at $($next.ToString('o'))."
}
catch {
$code = if ($_.Exception -is [System.Net.Mail.SmtpException]) {
'smtp_' + $_.Exception.StatusCode.ToString()
} elseif ($sendStarted) { 'provider_outcome_unknown' } else { 'before_send_error' }
if ($sendStarted) {
Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'uncertain' 'uncertain' $code
Pause-DispatchForReview $db $reservedDelivery
Write-Output "Delivery $reservedDelivery has uncertain provider outcome ($code). All campaigns paused for manual review."
}
elseif ($_.Exception.Message -match 'HTA session|Campaign was paused') {
Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'aborted' 'pending' 'session_closed_before_send'
Write-Output "Delivery $reservedDelivery was not sent because the HTA session closed or paused. It remains pending."
}
else {
Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'failed' 'failed' $code
Write-Output "Delivery $reservedDelivery failed before SMTP ($code)."
}
}
}
finally {
if ($client) { $client.Dispose() }
if ($message) { $message.Dispose() }
if ($password) { $password.Dispose() }
if ($db) { $db.Dispose() }
$lock.Dispose()
}

+ 86
- 0
tools/export-report.ps1 Parādīt failu

@@ -0,0 +1,86 @@
param(
[Parameter(Mandatory = $true)][int]$CampaignId,
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'),
[string]$OutputRoot
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'campaign-common.ps1')
if ($CampaignId -lt 1) { throw 'CampaignId must be positive' }
$context = Get-MailContext $ConfigPath
if (-not $OutputRoot) {
$OutputRoot = if ($context.Settings['REPORT_DIR']) { $context.Settings['REPORT_DIR'] } else { 'reports' }
}
if (-not [System.IO.Path]::IsPathRooted($OutputRoot)) {
$OutputRoot = Join-Path (Split-Path -Parent $ConfigPath) $OutputRoot
}
$OutputRoot = [System.IO.Path]::GetFullPath($OutputRoot)
$null = New-Item -ItemType Directory -Path $OutputRoot -Force
$db = Open-MailDatabase $context
try {
$command = New-DbCommand $db 'SELECT [Name] FROM [Campaign] WHERE [Id]=?'
$null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$command.Parameters[0].Value = $CampaignId
$name = $command.ExecuteScalar()
if ($null -eq $name -or $name -is [DBNull]) { throw 'Campaign not found' }
$suppression = @{}
$command = New-DbCommand $db 'SELECT [EmailKey],[Reason] FROM [Suppression]'
$reader = $command.ExecuteReader()
try {
while ($reader.Read()) { $suppression[[string]$reader.GetValue(0)] = [string]$reader.GetValue(1) }
}
finally { $reader.Dispose() }
$deliveries = New-Object System.Collections.ArrayList
$command = New-DbCommand $db 'SELECT [Id],[EmailKey],[Status],[AttemptCount],[LastAttemptAtUtc],[LastErrorCode] FROM [Delivery] WHERE [CampaignId]=? ORDER BY [Id]'
$null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$command.Parameters[0].Value = $CampaignId
$reader = $command.ExecuteReader()
try {
while ($reader.Read()) {
$key = [string]$reader.GetValue(1)
$at = if ($reader.IsDBNull(4)) { '' } else { ([DateTime]$reader.GetValue(4)).ToString('o') }
$errorCode = if ($reader.IsDBNull(5)) { '' } else { [string]$reader.GetValue(5) }
$reason = if ($suppression.ContainsKey($key)) { $suppression[$key] } else { '' }
$null = $deliveries.Add([pscustomobject]@{
CampaignId = $CampaignId; CampaignName = [string]$name;
DeliveryId = [int]$reader.GetValue(0); Email = $key;
Status = [string]$reader.GetValue(2); AttemptCount = [int]$reader.GetValue(3);
LastAttemptUtc = $at; LastErrorCode = $errorCode; SuppressionReason = $reason
})
}
}
finally { $reader.Dispose() }
$attempts = New-Object System.Collections.ArrayList
$command = New-DbCommand $db 'SELECT a.[Id],a.[DeliveryId],a.[StartedAtUtc],a.[EndedAtUtc],a.[Outcome],a.[SanitizedError] FROM [Attempt] AS a INNER JOIN [Delivery] AS d ON a.[DeliveryId]=d.[Id] WHERE d.[CampaignId]=? ORDER BY a.[Id]'
$null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
$command.Parameters[0].Value = $CampaignId
$reader = $command.ExecuteReader()
try {
while ($reader.Read()) {
$started = if ($reader.IsDBNull(2)) { '' } else { ([DateTime]$reader.GetValue(2)).ToString('o') }
$ended = if ($reader.IsDBNull(3)) { '' } else { ([DateTime]$reader.GetValue(3)).ToString('o') }
$errorText = if ($reader.IsDBNull(5)) { '' } else { [string]$reader.GetValue(5) }
$null = $attempts.Add([pscustomobject]@{
CampaignId = $CampaignId; AttemptId = [int]$reader.GetValue(0);
DeliveryId = [int]$reader.GetValue(1); StartedUtc = $started;
EndedUtc = $ended; Outcome = [string]$reader.GetValue(4);
SanitizedError = $errorText
})
}
}
finally { $reader.Dispose() }
$stamp = [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ') + '-' + [guid]::NewGuid().ToString('N').Substring(0,8)
$prefix = Join-Path $OutputRoot "campaign-$CampaignId-$stamp"
$deliveryPath = $prefix + '-deliveries.csv'
$attemptPath = $prefix + '-attempts.csv'
$deliveries | Export-Csv -LiteralPath $deliveryPath -NoTypeInformation -Encoding UTF8
if ($attempts.Count) {
$attempts | Export-Csv -LiteralPath $attemptPath -NoTypeInformation -Encoding UTF8
}
else {
'CampaignId,AttemptId,DeliveryId,StartedUtc,EndedUtc,Outcome,SanitizedError' |
Set-Content -LiteralPath $attemptPath -Encoding UTF8
}
Write-Output "Delivery report: $deliveryPath"
Write-Output "Attempt report: $attemptPath"
}
finally { $db.Dispose() }

+ 2
- 1
tools/preview-template.ps1 Parādīt failu

@@ -1,11 +1,12 @@
param(
[Parameter(Mandatory = $true)][int]$TemplateId,
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'),
[string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'temp\previews')
[string]$OutputRoot
)

$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'template-common.ps1')
if (-not $OutputRoot) { $OutputRoot = Join-Path (Split-Path -Parent $ConfigPath) 'temp\previews' }
if ($TemplateId -lt 1) { throw 'TemplateId must be positive' }
$context = Get-MailContext -ConfigPath $ConfigPath
$connection = Open-MailDatabase $context


+ 2
- 1
tools/review-campaign.ps1 Parādīt failu

@@ -1,10 +1,11 @@
param(
[Parameter(Mandatory = $true)][string]$RequestPath,
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'),
[string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'temp\previews')
[string]$OutputRoot
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'campaign-common.ps1')
if (-not $OutputRoot) { $OutputRoot = Join-Path (Split-Path -Parent $ConfigPath) 'temp\previews' }
$context = Get-MailContext -ConfigPath $ConfigPath
$request = Read-CampaignRequest $RequestPath
$db = Open-MailDatabase $context


+ 72
- 0
tools/send-approved-campaign-test.ps1 Parādīt failu

@@ -0,0 +1,72 @@
param(
[Parameter(Mandatory = $true)][int]$CampaignId,
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'),
[switch]$Send,
[string]$PickupDirectory
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'dispatch-common.ps1')
if ($CampaignId -lt 1) { throw 'CampaignId must be positive' }
if ([bool]$Send -eq [bool]$PickupDirectory) { throw 'Choose exactly one mode: -Send or -PickupDirectory' }
$context = Get-MailContext $ConfigPath
$settings = $context.Settings
if ($settings['SMTP_TLS'] -ne 'on' -or $settings['SMTP_STARTTLS'] -ne 'on') {
throw 'SMTP_TLS and SMTP_STARTTLS must both be on'
}
$port = 0
if (-not [int]::TryParse($settings['SMTP_PORT'], [ref]$port) -or $port -lt 1 -or $port -gt 65535) {
throw 'Invalid SMTP_PORT'
}
$testTo = [string]$settings['SMTP_TEST_TO']
$parsed = New-Object System.Net.Mail.MailAddress($testTo)
if ($parsed.Address -ne $testTo) { throw 'SMTP_TEST_TO must be a single bare email address' }
$db = Open-MailDatabase $context
$client = $null
$message = $null
$password = $null
try {
$campaign = Get-ApprovedCampaignSnapshot $db $CampaignId
$template = Get-TemplateSnapshot $db $campaign.TemplateId
$html = Add-CampaignFooter $template.Document @{
From = $campaign.From; ReplyTo = $campaign.ReplyTo; Postal = $campaign.Postal }
$message = New-Object System.Net.Mail.MailMessage
$message.From = New-Object System.Net.Mail.MailAddress($campaign.From)
$null = $message.ReplyToList.Add($campaign.ReplyTo)
$null = $message.To.Add($testTo)
$message.Subject = '[TEST] ' + $campaign.Subject
$message.SubjectEncoding = [System.Text.Encoding]::UTF8
$view = [System.Net.Mail.AlternateView]::CreateAlternateViewFromString(
$html, [System.Text.Encoding]::UTF8, 'text/html')
foreach ($image in $template.Images) {
$stream = New-Object System.IO.MemoryStream -ArgumentList (,([byte[]]$image.Bytes))
$resource = New-Object System.Net.Mail.LinkedResource($stream, $image.Type)
$resource.ContentId = $image.Cid
$resource.TransferEncoding = [System.Net.Mime.TransferEncoding]::Base64
$null = $view.LinkedResources.Add($resource)
}
$null = $message.AlternateViews.Add($view)
$client = New-Object System.Net.Mail.SmtpClient($settings['SMTP_HOST'], $port)
$client.Timeout = 20000
if ($Send) {
$password = Load-SmtpPassword $context
$client.UseDefaultCredentials = $false
$client.Credentials = New-Object System.Net.NetworkCredential -ArgumentList @($settings['SMTP_USER'], $password)
$client.EnableSsl = $true
}
else {
if (-not (Test-Path -LiteralPath $PickupDirectory -PathType Container)) {
throw 'Pickup directory does not exist'
}
$client.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::SpecifiedPickupDirectory
$client.PickupDirectoryLocation = [System.IO.Path]::GetFullPath($PickupDirectory)
}
$client.Send($message)
Write-CampaignAudit $db 'approved_campaign_test_accepted' ([string]$CampaignId) ("Internal test to $testTo")
Write-Output "Approved campaign $CampaignId test accepted for $testTo. Live delivery counts did not advance."
}
finally {
if ($client) { $client.Dispose() }
if ($message) { $message.Dispose() }
if ($password) { $password.Dispose() }
$db.Dispose()
}

+ 2
- 2
tools/test-campaign-db.ps1 Parādīt failu

@@ -8,7 +8,7 @@ $context = Get-MailContext $ConfigPath
$db = Open-MailDatabase $context
try {
if ($Mode -eq 'Seed') {
foreach ($address in @('one@example.invalid','two@example.invalid','blocked@example.invalid')) {
foreach ($address in @('one@example.invalid','two@example.invalid','three@example.invalid','blocked@example.invalid')) {
$command = $db.CreateCommand()
$command.CommandText = "INSERT INTO [Contact] ([EmailOriginal],[EmailKey],[BusinessName],[SourceReference],[Country],[EligibilityBasis],[CreatedAtUtc]) VALUES (?,?,?,?,?,?,?)"
foreach ($value in @($address,$address,'Example Business','https://example.invalid/listing','US','public_listing')) {
@@ -37,7 +37,7 @@ try {
$command.CommandText = "SELECT COUNT(*) FROM [Campaign] WHERE [Status]='approved_paused'"
if ([int]$command.ExecuteScalar() -ne 1) { throw 'Expected one approved paused campaign' }
$command.CommandText = "SELECT COUNT(*) FROM [Delivery] WHERE [Status]='pending'"
if ([int]$command.ExecuteScalar() -ne 1) { throw 'Expected one pending delivery' }
if ([int]$command.ExecuteScalar() -ne 2) { throw 'Expected two pending deliveries' }
$command.CommandText = 'SELECT COUNT(*) FROM [Attempt]'
if ([int]$command.ExecuteScalar() -ne 0) { throw 'Internal test unexpectedly advanced the live attempt queue' }
}


+ 9
- 4
tools/test-campaign-flow.ps1 Parādīt failu

@@ -48,7 +48,7 @@ try {
TemplateId = 1; ReviewToken = ''; TestReceived = $false }
Save-Request $request
$review = (& $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 $review.Count -ne 2 -or -not (Test-Path -LiteralPath $review.PreviewPath)) {
if ($LASTEXITCODE -ne 0 -or $review.Count -ne 3 -or -not (Test-Path -LiteralPath $review.PreviewPath)) {
throw 'Campaign review count or preview failed'
}
$previewHtml = Get-Content -LiteralPath $review.PreviewPath -Raw
@@ -61,7 +61,7 @@ try {
if ($LASTEXITCODE -ne 0) { throw 'Suppression fixture failed' }
Expect-Failure (Join-Path $PSScriptRoot 'send-campaign-test.ps1') @('-RequestPath',$requestPath,'-ConfigPath',$configPath,'-PickupDirectory',$pickup)
$review = (& $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 $review.Count -ne 1 -or $review.Token -eq $request.ReviewToken) {
if ($LASTEXITCODE -ne 0 -or $review.Count -ne 2 -or $review.Token -eq $request.ReviewToken) {
throw 'Suppression did not invalidate the review token'
}
$request.ReviewToken = $review.Token
@@ -72,17 +72,22 @@ try {
if ($messages.Count -ne 1) { throw 'Expected exactly one internal test MIME message' }
$mime = Get-Content -LiteralPath $messages[0].FullName -Raw
if ($mime -notmatch 'multipart/related' -or $mime -notmatch 'Content-ID:' -or
$mime -notmatch 'owner@example.invalid' -or $mime -match 'one@example.invalid|two@example.invalid') {
$mime -notmatch 'owner@example.invalid' -or $mime -match 'one@example.invalid|two@example.invalid|three@example.invalid') {
throw 'Internal test MIME has wrong recipient or inline image'
}
$request.TestReceived = $true
Save-Request $request
& $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'approve-campaign.ps1') -RequestPath $requestPath -ConfigPath $configPath
if ($LASTEXITCODE -ne 0) { throw 'Campaign approval failed' }
& $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'send-approved-campaign-test.ps1') -CampaignId 1 -ConfigPath $configPath -PickupDirectory $pickup
if ($LASTEXITCODE -ne 0) { throw 'Approved campaign test failed' }
if (@(Get-ChildItem -LiteralPath $pickup -Filter '*.eml' -File).Count -ne 2) {
throw 'Approved campaign test did not create exactly one more internal message'
}
& $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'test-campaign-db.ps1') -ConfigPath $configPath -Mode Check
if ($LASTEXITCODE -ne 0) { throw 'Campaign delivery state failed' }
Expect-Failure (Join-Path $PSScriptRoot 'approve-campaign.ps1') @('-RequestPath',$requestPath,'-ConfigPath',$configPath)
Write-Output 'Campaign review, stale-review rejection, internal-only MIME, and paused approval passed'
Write-Output 'Campaign review, internal tests before and after approval, and paused queue passed'
}
finally {
$resolvedWork = [System.IO.Path]::GetFullPath($work)


+ 125
- 0
tools/test-dispatch-flow.ps1 Parādīt failu

@@ -0,0 +1,125 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$work = Join-Path $root ('temp\dispatch-flow-' + [guid]::NewGuid().ToString('N'))
$templateRoot = Join-Path $work 'templates'
$dbPath = Join-Path $work 'test.mdb'
$configPath = Join-Path $work 'test.ini'
$requestPath = Join-Path $work 'request.json'
$pickup = Join-Path $work 'pickup'
$scriptHost = Join-Path $env:SystemRoot 'SysWOW64\cscript.exe'
$powerShellX86 = Join-Path $env:SystemRoot 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe'

function Call-X86([string]$File, [string[]]$Arguments) {
$result = & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot $File) @Arguments
if ($LASTEXITCODE -ne 0) { throw "$File failed" }
return $result
}
function Count-Messages {
return @(Get-ChildItem -LiteralPath $pickup -Filter '*.eml' -File).Count
}
function Expect-Failure([string]$File, [string[]]$Arguments) {
$ErrorActionPreference = 'Continue'
$null = & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot $File) @Arguments 2>$null
$failed = $LASTEXITCODE -ne 0
$ErrorActionPreference = 'Stop'
if (-not $failed) { throw "Expected $File to reject the action" }
}

try {
$null = New-Item -ItemType Directory -Path $templateRoot -Force
$null = New-Item -ItemType Directory -Path $pickup -Force
$example = Get-Content -LiteralPath (Join-Path $root 'config.example.ini') -Raw
$example.Replace('DB_PATH=data/MassMailer.mdb', ('DB_PATH=' + $dbPath)).Replace(
'TEMPLATE_ROOT=templates', ('TEMPLATE_ROOT=' + $templateRoot)).Replace(
'SMTP_TEST_TO=', 'SMTP_TEST_TO=owner@example.invalid').Replace(
'BUSINESS_POSTAL_ADDRESS=', 'BUSINESS_POSTAL_ADDRESS=123 Example Street, Test City, NY 10001') |
Set-Content -LiteralPath $configPath -Encoding UTF8
& $scriptHost //nologo (Join-Path $PSScriptRoot 'init-db.vbs') $configPath
if ($LASTEXITCODE -ne 0) { throw 'MDB setup failed' }
$null = Call-X86 'test-campaign-db.ps1' @('-ConfigPath',$configPath,'-Mode','Seed')
$templatePath = Join-Path $templateRoot 'offer.html'
[System.IO.File]::WriteAllText($templatePath, '<html><body><h1>Postcard offer</h1></body></html>',
[System.Text.Encoding]::UTF8)
$null = Call-X86 'import-template.ps1' @('-Path',$templatePath,'-ConfigPath',$configPath)
$request = @{ Name = 'Dispatch fixture'; Subject = 'Postcard advertising';
TemplateId = 1; ReviewToken = ''; TestReceived = $false }
$request | ConvertTo-Json -Compress | Set-Content -LiteralPath $requestPath -Encoding UTF8
$review = (Call-X86 'review-campaign.ps1' @('-RequestPath',$requestPath,'-ConfigPath',$configPath,
'-OutputRoot',(Join-Path $work 'previews'))) | ConvertFrom-Json
if ($review.Count -ne 3) { throw 'Expected three eligible contacts' }
$request.ReviewToken = $review.Token
$request | ConvertTo-Json -Compress | Set-Content -LiteralPath $requestPath -Encoding UTF8
$null = Call-X86 'send-campaign-test.ps1' @('-RequestPath',$requestPath,'-ConfigPath',$configPath,'-PickupDirectory',$pickup)
$request.TestReceived = $true
$request | ConvertTo-Json -Compress | Set-Content -LiteralPath $requestPath -Encoding UTF8
$null = Call-X86 'approve-campaign.ps1' @('-RequestPath',$requestPath,'-ConfigPath',$configPath)
$null = Get-ChildItem -LiteralPath $pickup -File | Remove-Item -Force

$null = Call-X86 'dispatch-control.ps1' @('-Action','Recover','-ConfigPath',$configPath)
$null = Call-X86 'dispatch-control.ps1' @('-Action','ReviewOptOut','-ConfigPath',$configPath)
$null = Call-X86 'dispatch-control.ps1' @('-Action','Resume','-CampaignId','1','-ConfigPath',$configPath)
$t0 = [DateTime]::UtcNow.AddSeconds(5)
Expect-Failure 'dispatch-once.ps1' @('-ConfigPath',$configPath,'-Send','-SessionPid','999999','-SessionToken','00000000000000000000000000000000')
$first = Call-X86 'dispatch-once.ps1' @('-ConfigPath',$configPath,'-PickupDirectory',$pickup,
'-TestNowUtc',$t0.ToString('o'))
if ($first -notmatch 'accepted' -or (Count-Messages) -ne 1) { throw 'First queued attempt failed' }
$settings = Get-Content -LiteralPath $configPath -Raw
$settings.Replace('QUEUE_INTERVAL=300', 'QUEUE_INTERVAL=600') |
Set-Content -LiteralPath $configPath -Encoding UTF8
$early = Call-X86 'dispatch-once.ps1' @('-ConfigPath',$configPath,'-PickupDirectory',$pickup,
'-TestNowUtc',$t0.AddSeconds(300).ToString('o'))
if ($early -notmatch 'Next attempt allowed' -or (Count-Messages) -ne 1) {
throw 'Longer interval did not protect the reserved slot'
}
$null = Call-X86 'test-campaign-db.ps1' @('-ConfigPath',$configPath,'-Mode','SuppressSecond')
$null = Call-X86 'dispatch-control.ps1' @('-Action','Recover','-ConfigPath',$configPath)
$closed = Call-X86 'dispatch-once.ps1' @('-ConfigPath',$configPath,'-PickupDirectory',$pickup,
'-TestNowUtc',$t0.AddSeconds(1200).ToString('o'))
if ($closed -notmatch 'paused' -or (Count-Messages) -ne 1) {
throw 'Recovery failed to pause dispatch after reopen'
}
$null = Call-X86 'dispatch-control.ps1' @('-Action','Resume','-CampaignId','1','-ConfigPath',$configPath)
$second = Call-X86 'dispatch-once.ps1' @('-ConfigPath',$configPath,'-PickupDirectory',$pickup,
'-TestNowUtc',$t0.AddSeconds(1200).ToString('o'),'-TestSimulateUnknownAfterSend')
if ($second -notmatch 'uncertain' -or (Count-Messages) -ne 2) {
throw 'Injected uncertain response did not pause after the second due attempt'
}
$sameSlot = Call-X86 'dispatch-once.ps1' @('-ConfigPath',$configPath,'-PickupDirectory',$pickup,
'-TestNowUtc',$t0.AddSeconds(1200).ToString('o'))
if ($sameSlot -notmatch 'paused' -or (Count-Messages) -ne 2) {
throw 'Uncertain outcome did not stop further sends'
}
Expect-Failure 'dispatch-control.ps1' @('-Action','Resume','-CampaignId','1','-ConfigPath',$configPath)
$status = (Call-X86 'dispatch-control.ps1' @('-Action','Status','-ConfigPath',$configPath)) | ConvertFrom-Json
if ($status.Counts.accepted -ne 1 -or $status.Counts.suppressed -ne 1 -or
$status.Counts.uncertain -ne 1) { throw 'Uncertain delivery counts are wrong' }
$null = Call-X86 'dispatch-control.ps1' @('-Action','ResolveAccepted','-DeliveryId','3','-ConfigPath',$configPath)
$status = (Call-X86 'dispatch-control.ps1' @('-Action','Status','-ConfigPath',$configPath)) | ConvertFrom-Json
if ($status.Counts.accepted -ne 2 -or $status.Counts.uncertain -ne 0) {
throw 'Manual uncertain reconciliation failed'
}
$reportFolder = Join-Path $work 'reports'
$null = Call-X86 'export-report.ps1' @('-CampaignId','1','-ConfigPath',$configPath,'-OutputRoot',$reportFolder)
$deliveryReport = @(Get-ChildItem -LiteralPath $reportFolder -Filter '*-deliveries.csv' -File)
$attemptReport = @(Get-ChildItem -LiteralPath $reportFolder -Filter '*-attempts.csv' -File)
if ($deliveryReport.Count -ne 1 -or $attemptReport.Count -ne 1) {
throw 'Campaign report files were not created'
}
$deliveryRows = @(Import-Csv -LiteralPath $deliveryReport[0].FullName)
$attemptRows = @(Import-Csv -LiteralPath $attemptReport[0].FullName)
if ($deliveryRows.Count -ne 3 -or $attemptRows.Count -ne 2 -or
@($deliveryRows | Where-Object Status -eq 'suppressed').Count -ne 1 -or
@($attemptRows | Where-Object Outcome -eq 'uncertain').Count -ne 1) {
throw 'Campaign report content is wrong'
}
$null = Call-X86 'dispatch-control.ps1' @('-Action','PauseAll','-ConfigPath',$configPath)
Write-Output 'Session guard, recovery, pacing, suppression, uncertainty, and reports passed'
}
finally {
$resolvedWork = [System.IO.Path]::GetFullPath($work)
$resolvedTemp = [System.IO.Path]::GetFullPath((Join-Path $root 'temp'))
if ($resolvedWork.StartsWith($resolvedTemp + [System.IO.Path]::DirectorySeparatorChar,
[StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $resolvedWork)) {
Remove-Item -LiteralPath $resolvedWork -Recurse -Force
}
}

+ 50
- 0
tools/test-hta-startup.ps1 Parādīt failu

@@ -0,0 +1,50 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$hta = Join-Path $root 'MassMailer.hta'
$mshta = Join-Path $env:SystemRoot 'SysWOW64\mshta.exe'
$existing = @(Get-CimInstance Win32_Process -Filter "Name='mshta.exe'" |
Where-Object { $_.CommandLine -like '*MassMailer.hta*' })
if ($existing.Count) { throw 'Close the existing MassMailer HTA before this startup smoke test' }
$started = $null
try {
$started = Start-Process -FilePath $mshta -ArgumentList ('"' + $hta + '"') -PassThru -WindowStyle Hidden
$active = @()
$session = $null
for ($wait = 0; $wait -lt 25; $wait++) {
Start-Sleep -Seconds 1
$active = @(Get-CimInstance Win32_Process -Filter "Name='mshta.exe'" |
Where-Object { $_.CommandLine -like '*MassMailer.hta*' })
if ($active.Count -eq 1) {
$session = Join-Path $root ('temp\session-' + $active[0].ProcessId + '.txt')
if (Test-Path -LiteralPath $session -PathType Leaf) { break }
}
}
if ($active.Count -ne 1) { throw 'Expected one running MassMailer HTA process' }
if (-not (Test-Path -LiteralPath $session -PathType Leaf)) {
throw 'HTA did not create an active dispatcher session heartbeat'
}
if ((Get-Item -LiteralPath $session).LastWriteTimeUtc -lt [DateTime]::UtcNow.AddSeconds(-20)) {
throw 'HTA dispatcher heartbeat is stale'
}
$powerShellX86 = Join-Path $env:SystemRoot 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe'
$token = [System.IO.File]::ReadAllText($session).Trim()
& $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'test-session-guard.ps1') -SessionPid $active[0].ProcessId -SessionToken $token
if ($LASTEXITCODE -ne 0) { throw 'Open-window process guard rejected the active HTA' }
Write-Output 'Hidden HTA startup and open-window dispatcher session passed'
}
finally {
$active = @(Get-CimInstance Win32_Process -Filter "Name='mshta.exe'" |
Where-Object { $_.CommandLine -like '*MassMailer.hta*' })
foreach ($process in $active) {
if ($process.ProcessId -notin @($existing | ForEach-Object ProcessId)) {
Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue
$session = Join-Path $root ('temp\session-' + $process.ProcessId + '.txt')
$full = [System.IO.Path]::GetFullPath($session)
$tempRoot = [System.IO.Path]::GetFullPath((Join-Path $root 'temp'))
if ($full.StartsWith($tempRoot + [System.IO.Path]::DirectorySeparatorChar,
[StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $full)) {
Remove-Item -LiteralPath $full -Force
}
}
}
}

+ 8
- 0
tools/test-session-guard.ps1 Parādīt failu

@@ -0,0 +1,8 @@
param(
[Parameter(Mandatory = $true)][int]$SessionPid,
[Parameter(Mandatory = $true)][string]$SessionToken
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'dispatch-common.ps1')
Assert-OpenHtaSession $SessionPid $SessionToken
Write-Output 'Live HTA process and heartbeat guard passed'

Notiek ielāde…
Atcelt
Saglabāt

Powered by TurnKey Linux.