diff --git a/.gitignore b/.gitignore index f006232..a36bc6c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ /templates/local/ /logs/ /backups/ +/reports/ /temp/ /tmp/ diff --git a/.specs/features/mass-mailer/tasks.md b/.specs/features/mass-mailer/tasks.md index c823591..8b2bc9b 100644 --- a/.specs/features/mass-mailer/tasks.md +++ b/.specs/features/mass-mailer/tasks.md @@ -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. diff --git a/.specs/features/mass-mailer/test-plan.md b/.specs/features/mass-mailer/test-plan.md index 9c7f7f7..6195967 100644 --- a/.specs/features/mass-mailer/test-plan.md +++ b/.specs/features/mass-mailer/test-plan.md @@ -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. diff --git a/MassMailer.hta b/MassMailer.hta index d996433..16dcdeb 100644 --- a/MassMailer.hta +++ b/MassMailer.hta @@ -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; + } + } - +

MassMailer

9 x 12 postcard marketing campaign workspace

-
Campaign sending is disabled in this build. The SMTP test sends one message only when you press its button.
+
+ + + + + + + +
+
+
Campaigns start only when you explicitly resume them. Sending stops when this window closes. Review replies and record opt-outs before resuming.
Contacts—
Suppressed—
@@ -491,18 +783,19 @@
ProviderChecking…
Send intervalChecking…
Internal test toChecking…
-
Send stateDisabled
+
Send stateInitializing…
-
+
+

SMTP setup and internal test

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.

-
+

Import business contacts

UTF-8 CSV needs an email header. Optional headers: business_name, source_reference, and acquired_at (YYYY-MM-DD). Every accepted row needs a source reference.

@@ -515,7 +808,7 @@
CSV rowEmailBusinessSourceResult
-
+

HTML templates

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.

@@ -525,9 +818,9 @@
-
+

Review and approve a campaign

-

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

+

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.

@@ -540,7 +833,23 @@
IDCampaignStatus
-
+
+

Open-window sending

+

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.

+ + + + + + +
+
+ + + + +
+

Suppress an address

Add unsubscribe replies here before the next send. Suppression applies across all campaigns.

@@ -549,6 +858,13 @@
+
+

Campaign reports

+

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.

+ + +
+
diff --git a/README.md b/README.md index 9948a2f..3907126 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config.example.ini b/config.example.ini index a00c058..5f4e19e 100644 --- a/config.example.ini +++ b/config.example.ini @@ -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 diff --git a/templates/README.md b/templates/README.md index fb1334c..d853a13 100644 --- a/templates/README.md +++ b/templates/README.md @@ -14,4 +14,4 @@ Example: ``` -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. diff --git a/tools/common.vbs b/tools/common.vbs index 1ed2a67..3357a41 100644 --- a/tools/common.vbs +++ b/tools/common.vbs @@ -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 diff --git a/tools/dispatch-common.ps1 b/tools/dispatch-common.ps1 new file mode 100644 index 0000000..fd3ad1a --- /dev/null +++ b/tools/dispatch-common.ps1 @@ -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() +} diff --git a/tools/dispatch-control.ps1 b/tools/dispatch-control.ps1 new file mode 100644 index 0000000..60e7b65 --- /dev/null +++ b/tools/dispatch-control.ps1 @@ -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() +} diff --git a/tools/dispatch-once.ps1 b/tools/dispatch-once.ps1 new file mode 100644 index 0000000..b1a09ee --- /dev/null +++ b/tools/dispatch-once.ps1 @@ -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() +} diff --git a/tools/export-report.ps1 b/tools/export-report.ps1 new file mode 100644 index 0000000..95e314b --- /dev/null +++ b/tools/export-report.ps1 @@ -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() } diff --git a/tools/preview-template.ps1 b/tools/preview-template.ps1 index ba660e4..fff0d28 100644 --- a/tools/preview-template.ps1 +++ b/tools/preview-template.ps1 @@ -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 diff --git a/tools/review-campaign.ps1 b/tools/review-campaign.ps1 index 3b0ddd7..2b250f8 100644 --- a/tools/review-campaign.ps1 +++ b/tools/review-campaign.ps1 @@ -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 diff --git a/tools/send-approved-campaign-test.ps1 b/tools/send-approved-campaign-test.ps1 new file mode 100644 index 0000000..91ac75b --- /dev/null +++ b/tools/send-approved-campaign-test.ps1 @@ -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() +} diff --git a/tools/test-campaign-db.ps1 b/tools/test-campaign-db.ps1 index 035a119..e75d7a0 100644 --- a/tools/test-campaign-db.ps1 +++ b/tools/test-campaign-db.ps1 @@ -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' } } diff --git a/tools/test-campaign-flow.ps1 b/tools/test-campaign-flow.ps1 index d15e055..c0987b6 100644 --- a/tools/test-campaign-flow.ps1 +++ b/tools/test-campaign-flow.ps1 @@ -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) diff --git a/tools/test-dispatch-flow.ps1 b/tools/test-dispatch-flow.ps1 new file mode 100644 index 0000000..f65bc8a --- /dev/null +++ b/tools/test-dispatch-flow.ps1 @@ -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, '

Postcard offer

', + [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 + } +} diff --git a/tools/test-hta-startup.ps1 b/tools/test-hta-startup.ps1 new file mode 100644 index 0000000..a6d4ad2 --- /dev/null +++ b/tools/test-hta-startup.ps1 @@ -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 + } + } + } +} diff --git a/tools/test-session-guard.ps1 b/tools/test-session-guard.ps1 new file mode 100644 index 0000000..fa3f52e --- /dev/null +++ b/tools/test-session-guard.ps1 @@ -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'