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 row
Email
Business
Source
Result
-
+
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 @@
ID
Campaign
Status
-
+
+
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: