From 9ea2920169684f9b8ad442407019fed4f2397451 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Thu, 22 Oct 2026 11:21:32 -0400 Subject: [PATCH] Add hidden tool runner, helper tests, and HTA/README updates Co-Authored-By: Claude Sonnet 5.5 --- MassMailer.hta | 146 +++++++++++++++++++---------- README.md | 1 + tools/run-hidden-tool.ps1 | 46 +++++++++ tools/test-hidden-helper.ps1 | 41 ++++++++ tools/test-hidden-hta-isolated.ps1 | 55 +++++++++++ 5 files changed, 239 insertions(+), 50 deletions(-) create mode 100644 tools/run-hidden-tool.ps1 create mode 100644 tools/test-hidden-helper.ps1 create mode 100644 tools/test-hidden-hta-isolated.ps1 diff --git a/MassMailer.hta b/MassMailer.hta index 16dcdeb..d7c1b84 100644 --- a/MassMailer.hta +++ b/MassMailer.hta @@ -274,6 +274,60 @@ return root + "\\" + folder + "\\WindowsPowerShell\\v1.0\\powershell.exe"; } function quoted(value) { return '"' + value + '"'; } + function writeJsonFile(path, value) { + var stream = new ActiveXObject("ADODB.Stream"); + try { + stream.Type = 2; + stream.Charset = "utf-8"; + stream.Open(); + stream.WriteText(JSON.stringify(value)); + stream.SaveToFile(path, 2); + } finally { if (stream.State === 1) stream.Close(); } + } + function readJsonFile(path) { + var stream = new ActiveXObject("ADODB.Stream"); + try { + stream.Type = 2; + stream.Charset = "utf-8"; + stream.Open(); + stream.LoadFromFile(path); + return JSON.parse(stream.ReadText().replace(/^\uFEFF/, "")); + } finally { if (stream.State === 1) stream.Close(); } + } + function finishHiddenTool(job) { + var fs = new ActiveXObject("Scripting.FileSystemObject"); + var result; + try { + if (!fs.FileExists(job.resultPath)) throw new Error("Helper exited without a result"); + result = readJsonFile(job.resultPath); + } finally { + if (fs.FileExists(job.requestPath)) fs.DeleteFile(job.requestPath); + if (fs.FileExists(job.resultPath)) fs.DeleteFile(job.resultPath); + } + if (!result.Success) throw new Error(result.Error || "Helper failed"); + return result.Output || ""; + } + function startHiddenTool(scriptName, parameters, wait) { + var fs = new ActiveXObject("Scripting.FileSystemObject"); + var folder = fs.BuildPath(projectRoot(), "temp"); + if (!fs.FolderExists(folder)) fs.CreateFolder(folder); + var requestPath = fs.BuildPath(folder, fs.GetTempName() + ".request.json"); + var job = { requestPath: requestPath, resultPath: requestPath + ".result.json", + startedAt: new Date().getTime(), warned: false }; + var bridge = fs.BuildPath(projectRoot(), "tools\\run-hidden-tool.ps1"); + var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " + + quoted(bridge) + " -RequestPath " + quoted(job.requestPath) + + " -ResultPath " + quoted(job.resultPath); + writeJsonFile(job.requestPath, { ScriptName: scriptName, + ConfigPath: fs.BuildPath(projectRoot(), "config.ini"), Parameters: parameters || {} }); + try { + new ActiveXObject("WScript.Shell").Run(command, 0, wait); + return wait ? finishHiddenTool(job) : job; + } catch (error) { + if (fs.FileExists(job.requestPath)) fs.DeleteFile(job.requestPath); + throw error; + } + } function configurePassword() { try { var fs = new ActiveXObject("Scripting.FileSystemObject"); @@ -289,18 +343,11 @@ function sendInternalTest() { try { var fs = new ActiveXObject("Scripting.FileSystemObject"); - var root = projectRoot(); - var settings = readSettings(fs.BuildPath(root, "config.ini")); + 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 one internal SMTP test to " + settings.SMTP_TEST_TO + "?")) return; - var script = fs.BuildPath(root, "tools\\test-smtp-message.ps1"); - var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " + - quoted(script) + " -Send"; - var process = new ActiveXObject("WScript.Shell").Exec(command); - var output = process.StdOut.ReadAll(); - var errors = process.StdErr.ReadAll(); document.getElementById("transportMessage").innerText = - (process.ExitCode === 0 ? "" : "Test failed. ") + output + errors; + runTemplateTool("test-smtp-message.ps1", { Send: true }); } catch (error) { document.getElementById("transportMessage").innerText = "Test failed: " + error.message; } @@ -336,23 +383,14 @@ if (db !== null && db.State === 1) db.Close(); } } - 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) + " -ConfigPath " + quoted(configPath) + " " + args; - var process = new ActiveXObject("WScript.Shell").Exec(command); - var output = process.StdOut.ReadAll(); - var errors = process.StdErr.ReadAll(); - if (process.ExitCode !== 0) throw new Error((output + errors).replace(/^\s+|\s+$/g, "")); - return output.replace(/^\s+|\s+$/g, ""); + function runTemplateTool(scriptName, parameters) { + return startHiddenTool(scriptName, parameters, true); } function importTemplate() { try { var path = document.getElementById("templatePath").value; if (!path || path.indexOf('"') !== -1) throw new Error("Select a template file"); - var result = runTemplateTool("import-template.ps1", "-Path " + quoted(path)); + var result = runTemplateTool("import-template.ps1", { Path: path }); document.getElementById("templateMessage").innerText = result; refreshTemplates(); refreshStatus(); @@ -364,7 +402,7 @@ try { var id = document.getElementById("templateVersion").value; if (!/^[1-9][0-9]*$/.test(id)) throw new Error("Select an imported template"); - var file = runTemplateTool("preview-template.ps1", "-TemplateId " + id); + var file = runTemplateTool("preview-template.ps1", { TemplateId: Number(id) }); document.getElementById("templatePreview").src = file; document.getElementById("templateMessage").innerText = "Showing the imported snapshot. This preview does not include the campaign footer yet."; @@ -441,7 +479,7 @@ TestReceived: document.getElementById("confirmReceived").checked }; } - function withCampaignRequest(scriptName, extraArgs) { + function withCampaignRequest(scriptName, extraParameters) { var fs = new ActiveXObject("Scripting.FileSystemObject"); var folder = fs.BuildPath(projectRoot(), "temp"); if (!fs.FolderExists(folder)) fs.CreateFolder(folder); @@ -454,7 +492,9 @@ stream.WriteText(JSON.stringify(campaignRequest())); stream.SaveToFile(path, 2); stream.Close(); - return runTemplateTool(scriptName, "-RequestPath " + quoted(path) + (extraArgs || "")); + var parameters = extraParameters || {}; + parameters.RequestPath = path; + return runTemplateTool(scriptName, parameters); } finally { if (stream.State === 1) stream.Close(); if (fs.FileExists(path)) fs.DeleteFile(path); @@ -463,7 +503,7 @@ function reviewCampaign() { invalidateCampaignReview(); try { - var data = JSON.parse(withCampaignRequest("review-campaign.ps1", "")); + var data = JSON.parse(withCampaignRequest("review-campaign.ps1")); pendingCampaignReview = data; document.getElementById("campaignReview").innerText = data.Count + " eligible recipients. From: " + data.From + ". Reply-to: " + @@ -482,7 +522,7 @@ if (!pendingCampaignReview) return; if (!window.confirm("Send this campaign test to " + pendingCampaignReview.TestTo + "?")) return; try { - var result = withCampaignRequest("send-campaign-test.ps1", " -Send"); + var result = withCampaignRequest("send-campaign-test.ps1", { Send: true }); campaignTestAccepted = true; document.getElementById("confirmReceived").disabled = false; document.getElementById("campaignMessage").innerText = result; @@ -501,7 +541,7 @@ if (!window.confirm("Approve " + pendingCampaignReview.Count + " recipients? The campaign will remain paused.")) return; try { - var result = withCampaignRequest("approve-campaign.ps1", ""); + var result = withCampaignRequest("approve-campaign.ps1"); document.getElementById("campaignMessage").innerText = result; invalidateCampaignReview(); refreshStatus(); @@ -566,7 +606,7 @@ var folder = fs.BuildPath(projectRoot(), "temp"); if (!fs.FolderExists(folder)) fs.CreateFolder(folder); document.getElementById("dispatchMessage").innerText = - runTemplateTool("dispatch-control.ps1", "-Action Recover"); + runTemplateTool("dispatch-control.ps1", { Action: "Recover" }); dispatchSessionPid = htaProcessId(); dispatchSessionToken = ""; for (var i = 0; i < 32; i++) @@ -596,13 +636,13 @@ var fs = new ActiveXObject("Scripting.FileSystemObject"); if (dispatchSessionPath && fs.FileExists(dispatchSessionPath)) fs.DeleteFile(dispatchSessionPath); - runTemplateTool("dispatch-control.ps1", "-Action PauseAll"); + runTemplateTool("dispatch-control.ps1", { Action: "PauseAll" }); } catch (ignored) { } } function refreshDispatchStatus() { - if (!dispatchReady || (dispatchProcess && dispatchProcess.Status === 0)) return; + if (!dispatchReady || dispatchProcess) return; try { - var status = JSON.parse(runTemplateTool("dispatch-control.ps1", "-Action Status")); + 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 = @@ -619,14 +659,25 @@ if (!dispatchReady) return; try { writeHeartbeat(); + var fs = new ActiveXObject("Scripting.FileSystemObject"); if (dispatchProcess) { - if (dispatchProcess.Status === 0) return; - var output = dispatchProcess.StdOut.ReadAll(); - var errors = dispatchProcess.StdErr.ReadAll(); - var exitCode = dispatchProcess.ExitCode; + if (!fs.FileExists(dispatchProcess.resultPath)) { + if (!dispatchProcess.warned && + new Date().getTime() - dispatchProcess.startedAt > 60000) { + dispatchProcess.warned = true; + document.getElementById("dispatchMessage").innerText = + "Sending helper has not returned. No further attempts will start until it finishes; pause and review the campaign."; + } + return; + } + var finished = dispatchProcess; dispatchProcess = null; - document.getElementById("dispatchMessage").innerText = - (exitCode === 0 ? "" : "Dispatch error: ") + output + errors; + try { + document.getElementById("dispatchMessage").innerText = finishHiddenTool(finished); + } catch (error) { + document.getElementById("dispatchMessage").innerText = + "Dispatch error: " + error.message; + } refreshStatus(); refreshCampaigns(); refreshDispatchStatus(); @@ -640,13 +691,8 @@ 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); + dispatchProcess = startHiddenTool("dispatch-once.ps1", { Send: true, + SessionPid: dispatchSessionPid, SessionToken: dispatchSessionToken }, false); } catch (error) { document.getElementById("dispatchMessage").innerText = "Dispatch tick failed: " + error.message; @@ -654,10 +700,10 @@ } 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); + var parameters = { Action: action }; + if (campaignId) parameters.CampaignId = Number(campaignId); + if (deliveryId) parameters.DeliveryId = Number(deliveryId); + var result = runTemplateTool("dispatch-control.ps1", parameters); document.getElementById("dispatchMessage").innerText = result; refreshCampaigns(); refreshUncertain(); @@ -693,7 +739,7 @@ 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"); + runTemplateTool("send-approved-campaign-test.ps1", { CampaignId: Number(id), Send: true }); } catch (error) { document.getElementById("dispatchMessage").innerText = "Approved campaign test failed: " + error.message; @@ -750,7 +796,7 @@ 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); + runTemplateTool("export-report.ps1", { CampaignId: Number(id) }); } catch (error) { document.getElementById("reportMessage").innerText = "Report export failed: " + error.message; diff --git a/README.md b/README.md index 3907126..f788e79 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Windows HTA application for individual marketing emails for a 9 x 12 postcard bu - 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`. +- Runs routine PowerShell helpers without opening console windows. The password setup action opens a console so the password can be entered privately. 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. diff --git a/tools/run-hidden-tool.ps1 b/tools/run-hidden-tool.ps1 new file mode 100644 index 0000000..e31c386 --- /dev/null +++ b/tools/run-hidden-tool.ps1 @@ -0,0 +1,46 @@ +param( + [Parameter(Mandatory = $true)][string]$RequestPath, + [Parameter(Mandatory = $true)][string]$ResultPath +) +$ErrorActionPreference = 'Stop' +$result = @{ Success = $false; Output = ''; Error = '' } +try { + $projectRoot = Split-Path -Parent $PSScriptRoot + $tempRoot = [System.IO.Path]::GetFullPath((Join-Path $projectRoot 'temp')) + foreach ($path in @($RequestPath, $ResultPath)) { + $resolved = [System.IO.Path]::GetFullPath($path) + if (-not $resolved.StartsWith($tempRoot + [System.IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw 'Hidden helper files must be under the project temp folder' + } + } + $request = Get-Content -LiteralPath $RequestPath -Raw -Encoding UTF8 | ConvertFrom-Json + $allowed = @('test-smtp-message.ps1', 'import-template.ps1', 'preview-template.ps1', + 'review-campaign.ps1', 'send-campaign-test.ps1', 'approve-campaign.ps1', + 'dispatch-control.ps1', 'dispatch-once.ps1', 'send-approved-campaign-test.ps1', + 'export-report.ps1') + if ($request.ScriptName -notin $allowed) { throw 'Unsupported hidden helper script' } + $scriptPath = Join-Path $PSScriptRoot $request.ScriptName + if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) { + throw 'Hidden helper script is missing' + } + $parameters = @{ ConfigPath = [string]$request.ConfigPath } + foreach ($property in $request.Parameters.PSObject.Properties) { + if ($property.Name -notmatch '^[A-Za-z][A-Za-z0-9]*$' -or + $property.Name -eq 'ConfigPath') { throw 'Invalid hidden helper parameter' } + $parameters[$property.Name] = $property.Value + } + $result.Output = ((& $scriptPath @parameters | Out-String).Trim()) + $result.Success = $true +} +catch { + $result.Error = $_.Exception.Message +} +finally { + $json = ConvertTo-Json -InputObject $result -Compress -Depth 4 + $temporaryPath = $ResultPath + '.writing' + [System.IO.File]::WriteAllText($temporaryPath, $json, + (New-Object System.Text.UTF8Encoding($false))) + [System.IO.File]::Move($temporaryPath, $ResultPath) +} +if (-not $result.Success) { exit 1 } diff --git a/tools/test-hidden-helper.ps1 b/tools/test-hidden-helper.ps1 new file mode 100644 index 0000000..856ffba --- /dev/null +++ b/tools/test-hidden-helper.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$tempFolder = Join-Path $root 'temp' +$null = New-Item -ItemType Directory -Path $tempFolder -Force +$powerShellX86 = Join-Path $env:SystemRoot 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe' +$bridge = Join-Path $PSScriptRoot 'run-hidden-tool.ps1' +$shell = New-Object -ComObject WScript.Shell +foreach ($wait in @($true, $false)) { + $token = [guid]::NewGuid().ToString('N') + $requestPath = Join-Path $tempFolder ($token + '.request.json') + $resultPath = Join-Path $tempFolder ($token + '.result.json') + try { + @{ ScriptName = 'dispatch-control.ps1'; ConfigPath = (Join-Path $root 'config.ini'); + Parameters = @{ Action = 'Status' } } | + ConvertTo-Json -Compress -Depth 4 | + Set-Content -LiteralPath $requestPath -Encoding UTF8 + $command = '"' + $powerShellX86 + '" -NoProfile -ExecutionPolicy Bypass -File "' + + $bridge + '" -RequestPath "' + $requestPath + '" -ResultPath "' + $resultPath + '"' + $exitCode = $shell.Run($command, 0, $wait) + if (-not $wait) { + for ($attempt = 0; $attempt -lt 160 -and + -not (Test-Path -LiteralPath $resultPath -PathType Leaf); $attempt++) { + Start-Sleep -Milliseconds 250 + } + } + if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) { + throw 'Hidden runner produced no result' + } + $response = Get-Content -LiteralPath $resultPath -Raw -Encoding UTF8 | ConvertFrom-Json + if (($wait -and $exitCode -ne 0) -or -not $response.Success) { + throw ('Hidden runner failed: ' + $response.Error) + } + $status = $response.Output | ConvertFrom-Json + if ($null -eq $status.Paused) { throw 'Hidden runner status response is incomplete' } + } + finally { + if (Test-Path -LiteralPath $requestPath) { Remove-Item -LiteralPath $requestPath -Force } + if (Test-Path -LiteralPath $resultPath) { Remove-Item -LiteralPath $resultPath -Force } + } +} +Write-Output 'Synchronous and asynchronous hidden helpers returned dispatch status' diff --git a/tools/test-hidden-hta-isolated.ps1 b/tools/test-hidden-hta-isolated.ps1 new file mode 100644 index 0000000..292b1d2 --- /dev/null +++ b/tools/test-hidden-hta-isolated.ps1 @@ -0,0 +1,55 @@ +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent $PSScriptRoot +$tempRoot = [System.IO.Path]::GetFullPath((Join-Path $root 'temp')) +$work = Join-Path $tempRoot ('hta-hidden-' + [guid]::NewGuid().ToString('N')) +$hta = Join-Path $work 'MassMailer.hta' +$started = $null +try { + $null = New-Item -ItemType Directory -Path $work -Force + Copy-Item -LiteralPath (Join-Path $root 'tools') -Destination (Join-Path $work 'tools') -Recurse + Copy-Item -LiteralPath (Join-Path $root 'src') -Destination (Join-Path $work 'src') -Recurse + $null = New-Item -ItemType Directory -Path (Join-Path $work 'templates') -Force + $app = Get-Content -LiteralPath (Join-Path $root 'MassMailer.hta') -Raw + $app.Replace('APPLICATIONNAME="MassMailer"', 'APPLICATIONNAME="MassMailerSmoke"') | + Set-Content -LiteralPath $hta -Encoding UTF8 + $dbPath = Join-Path $work 'data\test.mdb' + $null = New-Item -ItemType Directory -Path (Split-Path -Parent $dbPath) -Force + $settings = Get-Content -LiteralPath (Join-Path $root 'config.example.ini') -Raw + $settings.Replace('DB_PATH=data/MassMailer.mdb', ('DB_PATH=' + $dbPath)) | + Set-Content -LiteralPath (Join-Path $work 'config.ini') -Encoding UTF8 + $cscript = Join-Path $env:SystemRoot 'SysWOW64\cscript.exe' + & $cscript //nologo (Join-Path $work 'tools\init-db.vbs') (Join-Path $work 'config.ini') + if ($LASTEXITCODE -ne 0) { throw 'Isolated MDB initialization failed' } + $mshta = Join-Path $env:SystemRoot 'SysWOW64\mshta.exe' + $started = Start-Process -FilePath $mshta -ArgumentList ('"' + $hta + '"') -PassThru -WindowStyle Hidden + $session = $null + for ($wait = 0; $wait -lt 30; $wait++) { + Start-Sleep -Seconds 1 + $matches = @(Get-CimInstance Win32_Process -Filter "Name='mshta.exe'" | + Where-Object { $_.CommandLine -and $_.CommandLine.IndexOf($hta, + [StringComparison]::OrdinalIgnoreCase) -ge 0 }) + if ($matches.Count -eq 1) { + $session = Join-Path $work ('temp\session-' + $matches[0].ProcessId + '.txt') + if (Test-Path -LiteralPath $session -PathType Leaf) { break } + } + } + if ($matches.Count -ne 1 -or -not (Test-Path -LiteralPath $session -PathType Leaf)) { + throw 'Isolated HTA did not initialize its hidden dispatcher session' + } + Start-Sleep -Seconds 3 + Write-Output 'Isolated HTA startup passed while the main app remained open' +} +finally { + $matches = @(Get-CimInstance Win32_Process -Filter "Name='mshta.exe'" | + Where-Object { $_.CommandLine -and $_.CommandLine.IndexOf($hta, + [StringComparison]::OrdinalIgnoreCase) -ge 0 }) + foreach ($process in $matches) { + Stop-Process -Id $process.ProcessId -Force -ErrorAction SilentlyContinue + } + $resolvedWork = [System.IO.Path]::GetFullPath($work) + if (-not $resolvedWork.StartsWith($tempRoot + [System.IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe isolated test cleanup path' } + if (Test-Path -LiteralPath $resolvedWork) { + Remove-Item -LiteralPath $resolvedWork -Recurse -Force + } +}