diff --git a/.specs/features/mass-mailer/design.md b/.specs/features/mass-mailer/design.md
index ea7fe01..3f240ca 100644
--- a/.specs/features/mass-mailer/design.md
+++ b/.specs/features/mass-mailer/design.md
@@ -55,7 +55,7 @@ Use ADO parameterized commands for values and transactions for state transitions
The owner selects a local `.html` file. Image references must be relative to that file's folder, such as `images/postcard.jpg`. On import, resolve each path within that folder, reject traversal and absolute, remote, or data URLs, validate PNG/JPEG/GIF content and size, and snapshot bytes into `TemplateImage`. The preview uses the copied assets in a restricted context so template scripts cannot run in the privileged HTA. The mail adapter replaces each image reference with a `cid:` URL and builds an HTML `multipart/related` message with matching inline image parts. Recipient HTML must contain no `file://` or drive paths. General attachments are outside v1.
-The SMTP feasibility spike must prove mandatory STARTTLS, password authentication, and inline-image MIME on the supplied server, then inspect the received message. Do not commit to CDO until its transport and MIME behavior pass this test; a small transport helper is an acceptable implementation if required.
+The SMTP adapter uses the built-in 32-bit Windows PowerShell/.NET Framework `System.Net.Mail` APIs. A local capability probe completed a certificate-validated STARTTLS 1.3 handshake, and a pickup-folder test produced HTML `multipart/related` MIME with a CID-linked image. The explicit one-message test uses `SmtpClient.EnableSsl` and the configured SMTP credentials. Password authentication and received-message rendering still require the owner to enter the password locally and inspect the test inbox. The queued campaign path will use the same MIME construction after template snapshot and dispatch controls are built.
## Configuration and credentials
@@ -63,7 +63,7 @@ The validated `config.ini` is the source of truth for system-wide settings descr
| INI setting group | Meaning | MDB counterpart |
| --- | --- | --- |
-| `SMTP_*`, `SMTP_SECRET_ID` | Endpoint, auth identity, required TLS/STARTTLS, from and reply addresses, secret lookup | `ProtectedSecret` stores DPAPI ciphertext and rotation metadata. |
+| `SMTP_*`, `SMTP_SECRET_ID` | Endpoint, auth identity, required TLS/STARTTLS, from and reply addresses, internal test recipient, secret lookup | `ProtectedSecret` stores DPAPI ciphertext and rotation metadata. |
| `DB_PATH`, `DB_PROVIDER`, `DB_BITNESS` | MDB location, OLE DB provider, and launch architecture | `AppMeta` stores schema version. |
| `QUEUE_INTERVAL`, `SEND_MODE`, `DISPLAY_TIME_ZONE` | Account-wide minimum gap, allowed sending hours, display time | `DispatchState` stores pacing; `OperationalState` stores pause and review state. `SEND_MODE=always` means any hour **while the HTA is open**. |
| `TEMPLATE_ROOT`, `TEMPLATE_IMAGE_*`, `TEMPLATE_MESSAGE_MAX_BYTES` | Source folder, inline image mode, supported types, size limits | Template and image versions store approved snapshots. |
@@ -71,16 +71,16 @@ The validated `config.ini` is the source of truth for system-wide settings descr
`SMTP_TLS=on` means encryption is mandatory; `SMTP_STARTTLS=on` means the client must negotiate STARTTLS before authentication or message data. Port 587 is configurable. Invalid combinations, handshake failure, or invalid certificates block sending. `QUEUE_INTERVAL=300` means at least 300 seconds between attempts from this account across all campaigns. On interval change, use `max(persistedNextAllowedUtc, lastAttemptAtUtc + currentInterval)` so a new value cannot shorten the reserved gap.
-The password setup/rotation action accepts the password without writing it to a command line, file, or log, protects it with Windows DPAPI `CurrentUser`, and writes only ciphertext to `ProtectedSecret`. The HTA may show whether a secret exists; plaintext should exist only for the brief SMTP authentication call. A backup restored under another Windows identity requires password re-entry. This design needs a tested implementation because script hosts do not expose DPAPI directly; a small local helper may be required.
+The HTA starts a local interactive x86 PowerShell setup action. `Read-Host -AsSecureString` accepts the password without writing it to a command line, file, or log; `ConvertFrom-SecureString` uses Windows DPAPI for the current user. Only its ciphertext is stored in `ProtectedSecret`. The sender reads it with `ConvertTo-SecureString` and passes the secure value to `NetworkCredential`. Save, load, and rotation passed a disposable-MDB test with synthetic passwords. A backup restored under another Windows identity requires password re-entry. Password setup is allowed while the HTA is closed because it does not send mail.
## Open technical decisions
| Area | Decision to prove in Sprint 0 |
| --- | --- |
-| SMTP | Verify actual server authentication, STARTTLS certificate validation, inline MIME, size caps, and marketing policy. |
+| SMTP | STARTTLS and local inline MIME pass. Verify actual password authentication, received image rendering, size caps, and marketing policy. |
| MDB | Choose migration and backup procedure; verify the real startup gate with the 32-bit HTA and configured file. |
| UI responsiveness | Measure synchronous send duration and choose a bounded helper if the HTA blocks too long. |
-| DPAPI | Implement and test CurrentUser protection from the chosen script/helper boundary without exposing plaintext in logs or process arguments. |
+| DPAPI | Synthetic password save/load/rotation passes. Verify the owner's password works for a live internal test. |
## Technical source notes
@@ -89,3 +89,5 @@ The password setup/rotation action accepts the password without writing it to a
- Provider bitness matching: https://learn.microsoft.com/en-us/troubleshoot/power-platform/power-automate/desktop-flows/cannot-connect-access-database-engine-ole-db
- DPAPI CurrentUser: https://learn.microsoft.com/en-us/dotnet/api/system.security.cryptography.dataprotectionscope
- CID MIME references: https://www.rfc-editor.org/info/rfc2387/ and https://www.rfc-editor.org/info/rfc2392/
+- .NET SMTP STARTTLS behavior: https://learn.microsoft.com/en-us/dotnet/api/system.net.mail.smtpclient.enablessl
+- Windows PowerShell secure-string DPAPI behavior: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/convertfrom-securestring
diff --git a/.specs/features/mass-mailer/spec.md b/.specs/features/mass-mailer/spec.md
index 696ea5a..86492b4 100644
--- a/.specs/features/mass-mailer/spec.md
+++ b/.specs/features/mass-mailer/spec.md
@@ -13,7 +13,7 @@
7. Record attempt, provider response, accepted/failed/uncertain state, timestamps, and campaign summary. Provider acceptance is not proof of delivery.
8. Retry only transient failures with bounded attempts and provider-directed delay. Do not automatically resend uncertain outcomes.
9. Provide a working opt-out reply address. The owner checks that mailbox daily and records requests in the suppression store. The HTA checks that store before every send. US business-to-business marketing is covered by CAN-SPAM.
-10. Read all system-wide behavior from a validated INI: SMTP endpoint/auth identity/security flags; Access `.mdb` path/provider/bitness; from/reply address; minimum queue interval; send mode; display time zone; template root and image policy; opt-out review cadence; and business postal address. Store campaign-specific settings, template and image versions, contacts, suppressions, queue state, and opt-out review status in the `.mdb`. Store the SMTP password as a DPAPI-protected value in the `.mdb`, configured through a setup action. No operational value is hard-coded.
+10. Read all system-wide behavior from a validated INI: SMTP endpoint/auth identity/security flags and internal test recipient; Access `.mdb` path/provider/bitness; from/reply address; minimum queue interval; send mode; display time zone; template root and image policy; opt-out review cadence; and business postal address. Store campaign-specific settings, template and image versions, contacts, suppressions, queue state, and opt-out review status in the `.mdb`. Store the SMTP password as a DPAPI-protected value in the `.mdb`, configured through a setup action. No operational value is hard-coded.
11. Define when settings take effect: transport and queue settings are revalidated before each in-app dispatch; a changed interval cannot shorten a gap already reserved; sender identity, postal address, and selected template/image version are frozen when a campaign is approved; invalid or incomplete settings block approval or sending. Configuration changes must not silently start or resume a campaign.
12. Before the HTA starts, verify the requested 32-bit/64-bit process architecture, matching Access OLE DB provider, ability to open the configured `.mdb`, and schema version. If any check fails, show a specific error and do not open the sending UI. Recheck the connection before dispatch after any database error.
diff --git a/.specs/features/mass-mailer/tasks.md b/.specs/features/mass-mailer/tasks.md
new file mode 100644
index 0000000..0b89d15
--- /dev/null
+++ b/.specs/features/mass-mailer/tasks.md
@@ -0,0 +1,18 @@
+# MassMailer implementation tasks
+
+**Status:** In progress. Work in dependency order; a checked task has passed its listed verification.
+
+| ID | Deliverable | Depends on | Verify |
+| --- | --- | --- | --- |
+| F01 ✅ | Shared VBScript INI reader and validation | None | Valid example loads; malformed, missing, or duplicate keys fail with a useful message. |
+| F02 ✅ | MDB initializer with versioned schema | F01 | Fresh MDB, tables, unique indexes, and schema version 1 created in disposable integration test. |
+| F03 ✅ | Bitness-aware launcher and real MDB preflight | F01, F02 | Valid x86 path passes; missing MDB, x64 provider mismatch, invalid interval, and schema 2 fail. |
+| F04 ✅ | HTA status shell | F03 | Hidden-window smoke and scripted status query passed; no campaign send process exists. |
+| F05 ◐ | SMTP transport and protected secret feasibility | F02 | STARTTLS 1.3, DPAPI round trip/rotation, and inline-image pickup MIME pass; authenticated internal send and inbox check pending. |
+| 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 | Local images snapshot and preview; unsafe paths and active content fail. |
+| F08 | Campaign approval and test send | F05-F07 | Snapshot recipient/content/identity; explicit approval and internal-only test send. |
+| 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. |
+
+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 6bd3e2a..58a79a4 100644
--- a/.specs/features/mass-mailer/test-plan.md
+++ b/.specs/features/mass-mailer/test-plan.md
@@ -39,10 +39,13 @@
| T27 | Operations | Restore the database under another Windows identity | Campaign and suppression data restore; SMTP password cannot be decrypted and must be entered again before sending. |
| T28 | Integration | Close the HTA with a due campaign, wait past several intervals, then reopen | No sends occur while closed; reopening requires explicit resume and causes no catch-up burst. |
| T29 | Integration | Invoke provider probe from both 32-bit and 64-bit script hosts on target PC | Diagnostic reports actual MDB create/write/read capability per provider and architecture; startup uses the verified architecture. |
+| T30 | Integration | Save and rotate synthetic SMTP passwords in a disposable MDB, then reopen under the same Windows user | DPAPI value round trips and rotates; plaintext does not appear in INI or command arguments. |
+| T31 | Integration | Build internal test message to a local pickup folder | Exactly one HTML message is produced with `multipart/related` and a CID-linked inline image; no network send occurs. |
+| T32 | Manual integration | After local password setup, press the HTA's internal test button | Exactly one message goes to configured `SMTP_TEST_TO`; owner confirms authentication and image rendering in the inbox. |
## Release gates
-1. All P1 stories and T01-T29 applicable to the chosen provider pass.
+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.
3. Unsubscribe route is tested end to end; a recorded opt-out takes effect before the next dispatch. Any manual mailbox procedure has an owner and a tested processing schedule.
4. Operator receives deployment, backup/restore, pause, reconciliation, and incident instructions.
diff --git a/.specs/project/PROJECT.md b/.specs/project/PROJECT.md
index 2aad7d6..e3e0d6c 100644
--- a/.specs/project/PROJECT.md
+++ b/.specs/project/PROJECT.md
@@ -19,7 +19,7 @@ A Windows desktop tool for the owner's 9 x 12 postcard business to prepare and s
- The owner confirms the SMTP provider permits this marketing use; connection and STARTTLS compatibility still need a technical check.
- Backend: an Access `.mdb` database via 32-bit `Microsoft.ACE.OLEDB.12.0` on the current PC. The UI launcher must check process bitness and actually open the selected provider before starting the HTA. Current diagnostics passed `.mdb` create/write/read in 32-bit hosts and failed in 64-bit hosts.
- Configuration rule: system-wide settings are editable in a validated `.ini` file; campaign choices, approved template/image versions, contacts, suppressions, queue state, review status, and a protected SMTP password value are editable or managed through the `.mdb`. No operational value is compiled into the scripts.
-- Deliverables in this planning phase: requirements, architecture, Scrum stories, and test plan. No sending code or live email is in scope yet.
+- Current implementation: Access schema/startup checks, contact import and suppression, protected SMTP password setup, and an explicit one-message internal SMTP test. Campaign sending is not implemented yet.
## Working assumptions to validate
diff --git a/.specs/project/STATE.md b/.specs/project/STATE.md
new file mode 100644
index 0000000..bf018c3
--- /dev/null
+++ b/.specs/project/STATE.md
@@ -0,0 +1,19 @@
+# Project state
+
+## Current milestone
+
+F01-F04 and F06 are implemented and locally verified. F05 has a successful unauthenticated STARTTLS 1.3 handshake, protected password storage tests, and an inline-image MIME pickup test. The authenticated internal send and inbox rendering are pending local password setup. Campaign sending remains unavailable.
+
+## Decisions
+
+- Sending only while the HTA is open; reopening requires explicit resume.
+- Access `.mdb`; this PC has working x86 Jet/ACE providers and no working x64 provider in the diagnostic probe.
+- System settings in local INI; campaign and queue data in MDB; password protected with DPAPI CurrentUser when implemented.
+- No live business-contact sends during development or automated tests.
+- The owner supplied a private internal test inbox; it is stored only in ignored `config.ini`. The postal address remains blank until setup.
+
+## Next dependencies
+
+- Enter the SMTP password locally with the HTA setup action, then run its one-message internal test and inspect the received HTML/CID image.
+- Enter the business postal address in local `config.ini` before campaign approval.
+- Implement safe HTML template import, campaign approval, and in-app dispatch (F07-F09).
diff --git a/MassMailer.hta b/MassMailer.hta
new file mode 100644
index 0000000..aa45d91
--- /dev/null
+++ b/MassMailer.hta
@@ -0,0 +1,345 @@
+
+
+
+
+ MassMailer
+
+
+
+
+
+
+
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.
+
+
Contacts—
+
Suppressed—
+
Templates—
+
Campaigns—
+
+
+
DatabaseChecking…
+
ProviderChecking…
+
Send intervalChecking…
+
Internal test toChecking…
+
Send stateDisabled
+
+
+
+
+
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.
+
+
+
+
+
+
+
CSV row
Email
Business
Source
Result
+
+
+
+
Suppress an address
+
Add unsubscribe replies here before the next send. Suppression applies across all campaigns.
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
index 1797a2b..d06da5f 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,32 @@
-# MassMailer planning workspace
+# MassMailer
-Draft plan for a Windows HTA tool that schedules individual marketing emails for a 9 x 12 postcard business.
+Windows HTA application under construction for individual marketing emails for a 9 x 12 postcard business.
+
+## Current build
+
+- Creates an Access `.mdb` with versioned tables for contacts, suppressions, templates, campaigns, attempts, protected secrets, and audit records.
+- Checks the configured provider, process architecture, MDB, and schema before launching the HTA.
+- Imports UTF-8 contacts from a previewed CSV and records global suppressions.
+- 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.
+
+The app sends no marketing messages in this build. Closing the HTA stops all in-app activity.
+
+## Setup on this PC
+
+1. Copy `config.example.ini` to `config.ini` and edit local settings. Set `SMTP_TEST_TO` to the owner-controlled test inbox. `BUSINESS_POSTAL_ADDRESS` remains blank until you supply it; future campaign approval will require it. Never place the SMTP password in the INI.
+2. Run `C:\Windows\SysWOW64\cscript.exe //nologo tools\init-db.vbs` once to create `data\MassMailer.mdb`. It refuses to overwrite an existing database.
+3. Run `Start-MassMailer.cmd`. The launcher selects the configured script architecture and opens the MDB before starting the HTA. Use `C:\Windows\SysWOW64\cscript.exe //nologo tools\launch.vbs check` to check startup without opening the UI.
+4. Import a UTF-8 CSV with an `email` header. Optional columns are `business_name`, `source_reference`, and `acquired_at` (`YYYY-MM-DD`). Supply a source reference in the CSV or the form; preview and review rejected rows before importing.
+5. Enter opt-out replies in the suppression form. To prepare an internal transport test, press **Set or rotate SMTP password** and enter the password in the local console. Then press **Send one internal test** in the HTA. The test sends one HTML message with an inline image to `SMTP_TEST_TO`; no live test has been sent from this project yet.
+
+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. `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-smtp-capabilities.ps1` checks the configured server's STARTTLS handshake without authenticating or sending mail.
+
+## Planning documents
- [Project brief and open decisions](.specs/project/PROJECT.md)
- [Requirements, Scrum stories, and sprint outline](.specs/features/mass-mailer/spec.md)
@@ -9,4 +35,4 @@ Draft plan for a Windows HTA tool that schedules individual marketing emails for
- [Example SMTP configuration without a password](config.example.ini)
- [Access provider probe for 32-bit and 64-bit hosts](tools/probe-mdb-providers.vbs)
-These are planning documents plus a working Access provider diagnostic. The app will send only while its HTA window is open. System-wide settings belong in the INI; campaign settings, templates, contacts, queue state, and protected secrets belong in an Access `.mdb`. SMTP transport compatibility still needs an internal test message.
+System-wide settings belong in the INI; campaign settings, templates, contacts, queue state, and protected secrets belong in the Access MDB. Authenticated SMTP and inbox rendering still need an internal test message.
diff --git a/Start-MassMailer.cmd b/Start-MassMailer.cmd
new file mode 100644
index 0000000..e4d9c91
--- /dev/null
+++ b/Start-MassMailer.cmd
@@ -0,0 +1,7 @@
+@echo off
+"%SystemRoot%\System32\cscript.exe" //nologo "%~dp0tools\launch.vbs"
+if errorlevel 1 (
+ echo MassMailer did not start. Review the error above.
+ pause
+ exit /b 1
+)
diff --git a/config.example.ini b/config.example.ini
index c601b1b..a00c058 100644
--- a/config.example.ini
+++ b/config.example.ini
@@ -1,4 +1,4 @@
-# Example system-wide settings. Copy to a local config file when implementing.
+# Example system-wide settings. Copy to config.ini before first run.
# The SMTP password is configured through a setup action and stored as a
# DPAPI-protected value in the database. Never add SMTP_PASSWORD here.
SMTP_HOST=netsol-smtp-oxcs.hostingplatform.com
@@ -8,6 +8,7 @@ SMTP_TLS=on
SMTP_STARTTLS=on
SMTP_FROM=danielcovington@postcardmojo.com
SMTP_REPLY_TO=danielcovington@postcardmojo.com
+SMTP_TEST_TO=
SMTP_SECRET_ID=primary_smtp
DB_PATH=data/MassMailer.mdb
DB_PROVIDER=Microsoft.ACE.OLEDB.12.0
@@ -27,6 +28,7 @@ BUSINESS_POSTAL_ADDRESS=
# all campaigns using this SMTP account. TLS=on plus STARTTLS=on means the
# sender must upgrade to TLS and fail closed if the server cannot do so.
# SMTP_REPLY_TO is the monitored mailbox confirmed by the owner.
+# Set SMTP_TEST_TO to an owner-controlled address before the internal transport test.
# DB_PROVIDER and DB_BITNESS are checked before the HTA opens. On this PC,
# the listed ACE provider passed a 32-bit .mdb create/write/read probe.
# The image and message caps are initial application limits to validate
diff --git a/src/contacts.js b/src/contacts.js
new file mode 100644
index 0000000..6aaa37a
--- /dev/null
+++ b/src/contacts.js
@@ -0,0 +1,93 @@
+var MassMailerContacts = {
+ parseCsv: function (text) {
+ if (text.charCodeAt(0) === 65279) text = text.substring(1);
+ var rows = [], row = [], field = "", quoted = false, started = false;
+ for (var i = 0; i < text.length; i++) {
+ var ch = text.charAt(i);
+ if (quoted) {
+ if (ch === '"') {
+ if (text.charAt(i + 1) === '"') { field += '"'; i++; }
+ else quoted = false;
+ } else field += ch;
+ } else if (ch === '"') {
+ if (field.length !== 0) throw new Error("Unexpected quote in CSV row " + (rows.length + 1));
+ quoted = true;
+ started = true;
+ } else if (ch === ",") {
+ row.push(field); field = ""; started = true;
+ } else if (ch === "\r" || ch === "\n") {
+ row.push(field); field = "";
+ if (started || row.length > 1) rows.push(row);
+ row = []; started = false;
+ if (ch === "\r" && text.charAt(i + 1) === "\n") i++;
+ } else {
+ field += ch;
+ started = true;
+ }
+ }
+ if (quoted) throw new Error("Unclosed quote in CSV");
+ if (started || row.length > 0 || field.length > 0) {
+ row.push(field);
+ rows.push(row);
+ }
+ return rows;
+ },
+
+ emailKey: function (email) {
+ return email.replace(/^\s+|\s+$/g, "").toLowerCase();
+ },
+
+ isEmail: function (email) {
+ return email.length <= 254 && /^[^\s@,;<>]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(email);
+ },
+
+ isoDate: function (value) {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
+ var year = Number(value.substring(0, 4));
+ var month = Number(value.substring(5, 7));
+ var day = Number(value.substring(8, 10));
+ var date = new Date(year, month - 1, day);
+ return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
+ },
+
+ preview: function (text, fallbackSource, existing, suppressed) {
+ var rows = this.parseCsv(text);
+ if (rows.length < 2) throw new Error("CSV needs a header and at least one contact row");
+ var headers = {}, i, key;
+ for (i = 0; i < rows[0].length; i++) {
+ key = rows[0][i].replace(/^\s+|\s+$/g, "").toLowerCase();
+ if (headers["$" + key] !== undefined) throw new Error("Duplicate CSV header: " + key);
+ headers["$" + key] = i;
+ }
+ if (headers.$email === undefined) throw new Error("CSV needs an email column");
+ var accepted = [], rejected = [], seen = {};
+ for (i = 1; i < rows.length; i++) {
+ var cells = rows[i];
+ var email = this.cell(cells, headers, "email");
+ var emailKey = this.emailKey(email);
+ var businessName = this.cell(cells, headers, "business_name");
+ var source = this.cell(cells, headers, "source_reference") || fallbackSource;
+ var acquiredAt = this.cell(cells, headers, "acquired_at");
+ var reason = "";
+ if (cells.length > rows[0].length) reason = "Too many columns";
+ else if (!this.isEmail(email)) reason = "Invalid email";
+ else if (!source) reason = "Missing source reference";
+ else if (source.length > 2048) reason = "Source reference too long";
+ else if (businessName.length > 200) reason = "Business name too long";
+ else if (acquiredAt && !this.isoDate(acquiredAt)) reason = "acquired_at must be YYYY-MM-DD";
+ else if (suppressed[emailKey]) reason = "Suppressed address";
+ else if (existing[emailKey]) reason = "Already imported";
+ else if (seen[emailKey]) reason = "Duplicate in CSV";
+ var item = { line: i + 1, email: email, emailKey: emailKey,
+ businessName: businessName, source: source, acquiredAt: acquiredAt, reason: reason };
+ if (reason) rejected.push(item);
+ else { accepted.push(item); seen[emailKey] = true; }
+ }
+ return { accepted: accepted, rejected: rejected };
+ },
+
+ cell: function (cells, headers, name) {
+ if (headers["$" + name] === undefined) return "";
+ return (cells[headers["$" + name]] || "").replace(/^\s+|\s+$/g, "");
+ }
+};
diff --git a/tools/common.vbs b/tools/common.vbs
new file mode 100644
index 0000000..1ed2a67
--- /dev/null
+++ b/tools/common.vbs
@@ -0,0 +1,134 @@
+' Shared configuration code for 32-bit and 64-bit Windows Script Host.
+Option Explicit
+
+Function ProjectRoot()
+ Dim fs
+ Set fs = CreateObject("Scripting.FileSystemObject")
+ ProjectRoot = fs.GetParentFolderName(fs.GetParentFolderName(WScript.ScriptFullName))
+End Function
+
+Function ConfigFilePath()
+ Dim fs
+ Set fs = CreateObject("Scripting.FileSystemObject")
+ ConfigFilePath = fs.BuildPath(ProjectRoot(), "config.ini")
+End Function
+
+Function LoadConfig(configPath)
+ Dim fs, stream, content, lines, line, equalAt, key, value, settings, i
+ Set fs = CreateObject("Scripting.FileSystemObject")
+ If Not fs.FileExists(configPath) Then
+ Err.Raise vbObjectError + 1001, "MassMailer config", _
+ "Missing config.ini. Copy config.example.ini to config.ini and edit it."
+ End If
+
+ Set stream = CreateObject("ADODB.Stream")
+ stream.Type = 2
+ stream.Charset = "utf-8"
+ stream.Open
+ stream.LoadFromFile configPath
+ content = stream.ReadText
+ stream.Close
+ If Len(content) > 0 Then
+ If Left(content, 1) = ChrW(&HFEFF) Then content = Mid(content, 2)
+ End If
+
+ Set settings = CreateObject("Scripting.Dictionary")
+ settings.CompareMode = 1
+ lines = Split(Replace(Replace(content, vbCrLf, vbLf), vbCr, vbLf), vbLf)
+ For i = 0 To UBound(lines)
+ line = Trim(lines(i))
+ If Len(line) > 0 Then
+ If Left(line, 1) <> "#" And Left(line, 1) <> ";" Then
+ equalAt = InStr(line, "=")
+ If equalAt < 2 Then ConfigError "Invalid setting at line " & (i + 1)
+ key = UCase(Trim(Left(line, equalAt - 1)))
+ value = Trim(Mid(line, equalAt + 1))
+ If Not IsAllowedKey(key) Then ConfigError "Unknown setting: " & key
+ If settings.Exists(key) Then ConfigError "Duplicate setting: " & key
+ settings.Add key, value
+ End If
+ End If
+ Next
+
+ ValidateConfig settings
+ Set LoadConfig = settings
+End Function
+
+Sub ConfigError(message)
+ Err.Raise vbObjectError + 1002, "MassMailer config", message
+End Sub
+
+Function IsAllowedKey(key)
+ Dim name
+ IsAllowedKey = False
+ For Each name In Array("SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_TLS", _
+ "SMTP_STARTTLS", "SMTP_FROM", "SMTP_REPLY_TO", "SMTP_TEST_TO", "SMTP_SECRET_ID", _
+ "DB_PATH", "DB_PROVIDER", "DB_BITNESS", "QUEUE_INTERVAL", _
+ "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")
+ If key = name Then IsAllowedKey = True: Exit Function
+ Next
+End Function
+
+Sub ValidateConfig(settings)
+ Dim name
+ For Each name In Array("SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_TLS", _
+ "SMTP_STARTTLS", "SMTP_FROM", "SMTP_REPLY_TO", "SMTP_SECRET_ID", _
+ "DB_PATH", "DB_PROVIDER", "DB_BITNESS", "QUEUE_INTERVAL", _
+ "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")
+ If Not settings.Exists(name) Then ConfigError "Missing setting: " & name
+ If name <> "BUSINESS_POSTAL_ADDRESS" Then
+ If Len(settings(name)) = 0 Then ConfigError "Empty setting: " & name
+ End If
+ Next
+
+ CheckWholeNumber settings, "SMTP_PORT", 1, 65535
+ CheckWholeNumber settings, "QUEUE_INTERVAL", 60, 86400
+ CheckWholeNumber settings, "TEMPLATE_IMAGE_MAX_BYTES", 1, 10485760
+ CheckWholeNumber settings, "TEMPLATE_MESSAGE_MAX_BYTES", 1, 52428800
+ CheckWholeNumber settings, "OPT_OUT_REVIEW_HOURS", 1, 168
+ If CLng(settings("TEMPLATE_IMAGE_MAX_BYTES")) > CLng(settings("TEMPLATE_MESSAGE_MAX_BYTES")) Then
+ ConfigError "Image limit exceeds message limit"
+ End If
+ If LCase(settings("SMTP_TLS")) <> "on" Or LCase(settings("SMTP_STARTTLS")) <> "on" Then
+ ConfigError "SMTP_TLS and SMTP_STARTTLS must both be on"
+ End If
+ If LCase(settings("DB_BITNESS")) <> "x86" And LCase(settings("DB_BITNESS")) <> "x64" Then
+ ConfigError "DB_BITNESS must be x86 or x64"
+ End If
+ Select Case LCase(settings("DB_PROVIDER"))
+ Case "microsoft.jet.oledb.4.0", "microsoft.ace.oledb.12.0", "microsoft.ace.oledb.16.0"
+ Case Else: ConfigError "Unsupported DB_PROVIDER"
+ End Select
+ If LCase(settings("SEND_MODE")) <> "always" Then ConfigError "SEND_MODE must be always"
+ If LCase(settings("DISPLAY_TIME_ZONE")) <> "local" Then ConfigError "DISPLAY_TIME_ZONE must be local"
+ If LCase(settings("TEMPLATE_IMAGE_MODE")) <> "inline_cid" Then ConfigError "TEMPLATE_IMAGE_MODE must be inline_cid"
+End Sub
+
+Sub CheckWholeNumber(settings, name, minimum, maximum)
+ Dim re, value
+ Set re = CreateObject("VBScript.RegExp")
+ re.Pattern = "^[0-9]+$"
+ If Not re.Test(settings(name)) Then ConfigError name & " must be a whole number"
+ If Len(settings(name)) > 10 Then ConfigError name & " is too large"
+ value = CDbl(settings(name))
+ If value < minimum Or value > maximum Then
+ ConfigError name & " must be between " & minimum & " and " & maximum
+ End If
+End Sub
+
+Function ResolveDbPath(settings)
+ Dim fs, path
+ Set fs = CreateObject("Scripting.FileSystemObject")
+ path = settings("DB_PATH")
+ If Left(path, 2) = "\\" Then ConfigError "DB_PATH must be on a local drive"
+ If fs.GetDriveName(path) = "" Then path = fs.BuildPath(ProjectRoot(), path)
+ path = fs.GetAbsolutePathName(path)
+ If LCase(fs.GetExtensionName(path)) <> "mdb" Then ConfigError "DB_PATH must end in .mdb"
+ ResolveDbPath = path
+End Function
diff --git a/tools/init-db.vbs b/tools/init-db.vbs
new file mode 100644
index 0000000..1cf2e97
--- /dev/null
+++ b/tools/init-db.vbs
@@ -0,0 +1,106 @@
+' Create the first local Access database. Refuses to overwrite an existing file.
+Option Explicit
+
+Dim fs, source, settings, dbPath, folder, catalog, db, sql, configPath
+Set fs = CreateObject("Scripting.FileSystemObject")
+source = fs.BuildPath(fs.GetParentFolderName(WScript.ScriptFullName), "common.vbs")
+ExecuteGlobal fs.OpenTextFile(source, 1).ReadAll
+If WScript.Arguments.Count > 1 Then StopWithError "Usage: init-db.vbs [config path]"
+configPath = ConfigFilePath()
+If WScript.Arguments.Count = 1 Then configPath = WScript.Arguments(0)
+
+On Error Resume Next
+Set settings = LoadConfig(configPath)
+If Err.Number <> 0 Then StopWithError "Configuration: " & Err.Description
+dbPath = ResolveDbPath(settings)
+If Err.Number <> 0 Then StopWithError "Database path: " & Err.Description
+On Error GoTo 0
+
+If fs.FileExists(dbPath) Then StopWithError "Database already exists: " & dbPath
+folder = fs.GetParentFolderName(dbPath)
+If Not fs.FolderExists(folder) Then CreateFolders folder
+
+On Error Resume Next
+Set catalog = CreateObject("ADOX.Catalog")
+catalog.Create "Provider=" & settings("DB_PROVIDER") & ";Data Source=" & dbPath & _
+ ";Jet OLEDB:Engine Type=5;"
+If Err.Number <> 0 Then StopWithError "Cannot create MDB with " & settings("DB_PROVIDER") & _
+ " in this script host: " & Err.Description
+Set catalog = Nothing
+Set db = CreateObject("ADODB.Connection")
+db.Open "Provider=" & settings("DB_PROVIDER") & ";Data Source=" & dbPath & ";"
+If Err.Number <> 0 Then StopWithError "Cannot open new MDB: " & Err.Description
+On Error GoTo 0
+
+RunSql "CREATE TABLE [AppMeta] ([SchemaVersion] INTEGER NOT NULL, [CreatedAtUtc] DATETIME)"
+RunSql "CREATE TABLE [Contact] ([Id] COUNTER CONSTRAINT [pkContact] PRIMARY KEY, " & _
+ "[EmailOriginal] TEXT(254) NOT NULL, [EmailKey] TEXT(254) NOT NULL, " & _
+ "[BusinessName] TEXT(200), [SourceReference] MEMO, [AcquiredAtUtc] DATETIME, " & _
+ "[Country] TEXT(2), [EligibilityBasis] TEXT(100), [CreatedAtUtc] DATETIME)"
+RunSql "CREATE TABLE [Suppression] ([EmailKey] TEXT(254) NOT NULL, [Reason] TEXT(40) NOT NULL, " & _
+ "[RecordedAtUtc] DATETIME, [EvidenceReference] MEMO)"
+RunSql "CREATE TABLE [TemplateVersion] ([Id] COUNTER CONSTRAINT [pkTemplateVersion] PRIMARY KEY, " & _
+ "[TemplateName] TEXT(200) NOT NULL, [VersionNumber] INTEGER NOT NULL, " & _
+ "[HtmlContent] MEMO NOT NULL, [ContentHash] TEXT(64), [ImportedAtUtc] DATETIME, " & _
+ "[ValidationStatus] TEXT(30))"
+RunSql "CREATE TABLE [TemplateImage] ([Id] COUNTER CONSTRAINT [pkTemplateImage] PRIMARY KEY, " & _
+ "[TemplateVersionId] INTEGER NOT NULL, [OriginalRelativePath] TEXT(255) NOT NULL, " & _
+ "[ContentType] TEXT(40) NOT NULL, [ContentHash] TEXT(64), " & _
+ "[SnapshotBytes] LONGBINARY, [ContentId] TEXT(200))"
+RunSql "CREATE TABLE [Campaign] ([Id] COUNTER CONSTRAINT [pkCampaign] PRIMARY KEY, " & _
+ "[Name] TEXT(200) NOT NULL, [SenderKey] TEXT(254) NOT NULL, " & _
+ "[FromSnapshot] TEXT(254), [ReplyToSnapshot] TEXT(254), " & _
+ "[PostalAddressSnapshot] MEMO, [Subject] TEXT(255), [TemplateVersionId] INTEGER, " & _
+ "[Status] TEXT(30) NOT NULL, [StartAtUtc] DATETIME, [ApprovedAtUtc] DATETIME)"
+RunSql "CREATE TABLE [Delivery] ([Id] COUNTER CONSTRAINT [pkDelivery] PRIMARY KEY, " & _
+ "[CampaignId] INTEGER NOT NULL, [ContactId] INTEGER NOT NULL, " & _
+ "[EmailKey] TEXT(254) NOT NULL, [Status] TEXT(30) NOT NULL, " & _
+ "[AttemptCount] INTEGER NOT NULL, [LastAttemptAtUtc] DATETIME, " & _
+ "[ProviderMessageId] TEXT(200), [LastErrorCode] TEXT(100))"
+RunSql "CREATE TABLE [Attempt] ([Id] COUNTER CONSTRAINT [pkAttempt] PRIMARY KEY, " & _
+ "[DeliveryId] INTEGER NOT NULL, [StartedAtUtc] DATETIME, [EndedAtUtc] DATETIME, " & _
+ "[Outcome] TEXT(30) NOT NULL, [ProviderRequestId] TEXT(200), [SanitizedError] MEMO)"
+RunSql "CREATE TABLE [DispatchState] ([SenderKey] TEXT(254) NOT NULL, " & _
+ "[LastAttemptAtUtc] DATETIME, [NextAllowedAttemptUtc] DATETIME)"
+RunSql "CREATE TABLE [OperationalState] ([Id] INTEGER NOT NULL, [GlobalPause] BIT NOT NULL, " & _
+ "[LastOptOutReviewAtUtc] DATETIME, [LastConfigHash] TEXT(64))"
+RunSql "CREATE TABLE [ProtectedSecret] ([Id] TEXT(100) NOT NULL, [Purpose] TEXT(50) NOT NULL, " & _
+ "[DpapiCiphertext] LONGBINARY, [CreatedAtUtc] DATETIME, [RotatedAtUtc] DATETIME)"
+RunSql "CREATE TABLE [AuditEvent] ([Id] COUNTER CONSTRAINT [pkAuditEvent] PRIMARY KEY, " & _
+ "[Actor] TEXT(100), [Action] TEXT(100) NOT NULL, [EntityId] TEXT(100), " & _
+ "[TimestampUtc] DATETIME, [Detail] MEMO)"
+
+RunSql "CREATE UNIQUE INDEX [uxContactEmail] ON [Contact] ([EmailKey])"
+RunSql "CREATE UNIQUE INDEX [uxSuppressionEmail] ON [Suppression] ([EmailKey])"
+RunSql "CREATE UNIQUE INDEX [uxDeliveryCampaignEmail] ON [Delivery] ([CampaignId], [EmailKey])"
+RunSql "CREATE UNIQUE INDEX [uxDispatchSender] ON [DispatchState] ([SenderKey])"
+RunSql "CREATE UNIQUE INDEX [uxProtectedSecretId] ON [ProtectedSecret] ([Id])"
+RunSql "CREATE INDEX [ixDeliveryStatus] ON [Delivery] ([Status], [CampaignId])"
+RunSql "CREATE INDEX [ixAttemptDelivery] ON [Attempt] ([DeliveryId])"
+RunSql "CREATE INDEX [ixTemplateImageVersion] ON [TemplateImage] ([TemplateVersionId])"
+
+RunSql "INSERT INTO [AppMeta] ([SchemaVersion]) VALUES (1)"
+RunSql "INSERT INTO [OperationalState] ([Id], [GlobalPause]) VALUES (1, TRUE)"
+db.Close
+WScript.Echo "Created MDB schema version 1: " & dbPath
+WScript.Quit 0
+
+Sub RunSql(statement)
+ On Error Resume Next
+ db.Execute statement
+ If Err.Number <> 0 Then StopWithError "Schema initialization failed: " & Err.Description & _
+ vbCrLf & "Statement: " & statement
+ On Error GoTo 0
+End Sub
+
+Sub CreateFolders(path)
+ Dim parent
+ parent = fs.GetParentFolderName(path)
+ If Not fs.FolderExists(parent) Then CreateFolders parent
+ fs.CreateFolder path
+End Sub
+
+Sub StopWithError(message)
+ WScript.Echo "ERROR: " & message
+ WScript.Quit 1
+End Sub
diff --git a/tools/launch.vbs b/tools/launch.vbs
new file mode 100644
index 0000000..485adbd
--- /dev/null
+++ b/tools/launch.vbs
@@ -0,0 +1,107 @@
+' Launch the HTA only after the configured MDB passes a real connection check.
+Option Explicit
+
+Dim fs, shell, source, settings, desired, current, root, hostPath, dbPath
+Dim db, rows, schemaVersion, htaPath, exitCode, checkOnly, childCommand, child, configPath
+Set fs = CreateObject("Scripting.FileSystemObject")
+Set shell = CreateObject("WScript.Shell")
+source = fs.BuildPath(fs.GetParentFolderName(WScript.ScriptFullName), "common.vbs")
+ExecuteGlobal fs.OpenTextFile(source, 1).ReadAll
+
+checkOnly = (WScript.Arguments.Count > 0)
+configPath = ConfigFilePath()
+If checkOnly Then
+ If LCase(WScript.Arguments(0)) <> "check" Or WScript.Arguments.Count > 2 Then
+ StopWithError "Usage: launch.vbs [check [config path]]"
+ End If
+ If WScript.Arguments.Count = 2 Then configPath = WScript.Arguments(1)
+End If
+
+On Error Resume Next
+Set settings = LoadConfig(configPath)
+If Err.Number <> 0 Then StopWithError "Configuration: " & Err.Description
+dbPath = ResolveDbPath(settings)
+If Err.Number <> 0 Then StopWithError "Database path: " & Err.Description
+On Error GoTo 0
+
+desired = LCase(settings("DB_BITNESS"))
+current = HostBitness()
+If current <> desired Then
+ hostPath = TargetHost(desired, current)
+ If Not fs.FileExists(hostPath) Then StopWithError "Missing " & desired & " script host: " & hostPath
+ childCommand = Quote(hostPath) & " //nologo " & Quote(WScript.ScriptFullName)
+ If checkOnly Then childCommand = childCommand & " check " & Quote(configPath)
+ Set child = shell.Exec(childCommand)
+ WScript.Echo child.StdOut.ReadAll
+ If Not child.StdErr.AtEndOfStream Then WScript.Echo child.StdErr.ReadAll
+ exitCode = child.ExitCode
+ WScript.Quit exitCode
+End If
+
+If Not fs.FileExists(dbPath) Then StopWithError "MDB not found: " & dbPath & _
+ vbCrLf & "Run tools\init-db.vbs once in the configured " & desired & " host."
+On Error Resume Next
+Set db = CreateObject("ADODB.Connection")
+db.Open "Provider=" & settings("DB_PROVIDER") & ";Data Source=" & dbPath & ";"
+If Err.Number <> 0 Then StopWithError "Cannot open MDB with " & settings("DB_PROVIDER") & _
+ " in " & desired & " host: " & Err.Description
+Set rows = db.Execute("SELECT [SchemaVersion] FROM [AppMeta]")
+If Err.Number <> 0 Then StopWithError "MDB schema check failed: " & Err.Description
+If rows.EOF Then StopWithError "MDB AppMeta is empty"
+schemaVersion = CLng(rows.Fields("SchemaVersion").Value)
+If Err.Number <> 0 Then StopWithError "Invalid MDB schema version"
+If schemaVersion <> 1 Then StopWithError "Unsupported MDB schema version: " & schemaVersion
+rows.MoveNext
+If Not rows.EOF Then StopWithError "MDB AppMeta has multiple version rows"
+rows.Close
+db.Close
+On Error GoTo 0
+
+root = ProjectRoot()
+htaPath = fs.BuildPath(root, "MassMailer.hta")
+If Not fs.FileExists(htaPath) Then StopWithError "HTA not found: " & htaPath
+hostPath = fs.BuildPath(fs.GetParentFolderName(WScript.FullName), "mshta.exe")
+If Not fs.FileExists(hostPath) Then StopWithError "HTA host not found: " & hostPath
+WScript.Echo "MDB preflight passed (" & desired & ", schema 1): " & dbPath
+If checkOnly Then WScript.Quit 0
+shell.Run Quote(hostPath) & " " & Quote(htaPath), 1, False
+WScript.Quit 0
+
+Function HostBitness()
+ Dim processEnv
+ Set processEnv = shell.Environment("PROCESS")
+ If InStr(LCase(WScript.FullName), "\syswow64\") > 0 Then
+ HostBitness = "x86"
+ ElseIf Len(processEnv("PROCESSOR_ARCHITEW6432")) > 0 Then
+ HostBitness = "x86"
+ ElseIf InStr(LCase(processEnv("PROCESSOR_ARCHITECTURE")), "64") > 0 Then
+ HostBitness = "x64"
+ Else
+ HostBitness = "x86"
+ End If
+End Function
+
+Function TargetHost(wanted, actual)
+ Dim windowsPath
+ windowsPath = shell.ExpandEnvironmentStrings("%SystemRoot%")
+ If wanted = "x86" Then
+ If fs.FolderExists(fs.BuildPath(windowsPath, "SysWOW64")) Then
+ TargetHost = fs.BuildPath(windowsPath, "SysWOW64\cscript.exe")
+ Else
+ TargetHost = fs.BuildPath(windowsPath, "System32\cscript.exe")
+ End If
+ ElseIf actual = "x86" Then
+ TargetHost = fs.BuildPath(windowsPath, "Sysnative\cscript.exe")
+ Else
+ TargetHost = fs.BuildPath(windowsPath, "System32\cscript.exe")
+ End If
+End Function
+
+Function Quote(value)
+ Quote = Chr(34) & value & Chr(34)
+End Function
+
+Sub StopWithError(message)
+ WScript.Echo "ERROR: " & message
+ WScript.Quit 1
+End Sub
diff --git a/tools/mail-common.ps1 b/tools/mail-common.ps1
new file mode 100644
index 0000000..a19fe07
--- /dev/null
+++ b/tools/mail-common.ps1
@@ -0,0 +1,111 @@
+$ErrorActionPreference = 'Stop'
+
+function Get-MailContext {
+ param([string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'))
+
+ $settings = @{}
+ foreach ($line in Get-Content -LiteralPath $ConfigPath -Encoding UTF8) {
+ $trimmed = $line.Trim()
+ if (-not $trimmed -or $trimmed.StartsWith('#') -or $trimmed.StartsWith(';')) { continue }
+ $parts = $trimmed.Split('=', 2)
+ if ($parts.Count -ne 2) { throw "Invalid configuration line in $ConfigPath" }
+ $name = $parts[0].Trim().ToUpperInvariant()
+ if ($settings.ContainsKey($name)) { throw "Duplicate setting: $name" }
+ $settings[$name] = $parts[1].Trim()
+ }
+ foreach ($name in @('DB_PATH', 'DB_PROVIDER', 'DB_BITNESS', 'SMTP_SECRET_ID', 'SMTP_USER')) {
+ if (-not $settings.ContainsKey($name) -or -not $settings[$name]) {
+ throw "Missing setting: $name"
+ }
+ }
+ if ($settings['DB_PROVIDER'] -notin @('Microsoft.Jet.OLEDB.4.0',
+ 'Microsoft.ACE.OLEDB.12.0', 'Microsoft.ACE.OLEDB.16.0')) {
+ throw 'Unsupported DB_PROVIDER'
+ }
+ $actualBitness = if ([IntPtr]::Size -eq 4) { 'x86' } else { 'x64' }
+ if ($settings['DB_BITNESS'] -ne $actualBitness) {
+ throw "Run this setup under $($settings['DB_BITNESS']) PowerShell"
+ }
+ $databasePath = $settings['DB_PATH']
+ if ($databasePath.StartsWith('\\')) { throw 'DB_PATH must be on a local drive' }
+ if (-not [System.IO.Path]::IsPathRooted($databasePath)) {
+ $databasePath = Join-Path (Split-Path -Parent $PSScriptRoot) $databasePath
+ }
+ $databasePath = [System.IO.Path]::GetFullPath($databasePath)
+ if ([System.IO.Path]::GetExtension($databasePath) -ne '.mdb') { throw 'DB_PATH must end in .mdb' }
+ if (-not (Test-Path -LiteralPath $databasePath -PathType Leaf)) {
+ throw "MDB not found: $databasePath"
+ }
+ return @{ Settings = $settings; DatabasePath = $databasePath }
+}
+
+function Open-MailDatabase {
+ param($Context)
+ $builder = New-Object System.Data.OleDb.OleDbConnectionStringBuilder
+ $builder.Provider = $Context.Settings['DB_PROVIDER']
+ $builder['Data Source'] = $Context.DatabasePath
+ $connection = New-Object System.Data.OleDb.OleDbConnection($builder.ConnectionString)
+ $connection.Open()
+ return $connection
+}
+
+function Save-SmtpPassword {
+ param($Context, [System.Security.SecureString]$Password)
+ $encrypted = ConvertFrom-SecureString -SecureString $Password
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($encrypted)
+ $connection = Open-MailDatabase $Context
+ try {
+ $check = $connection.CreateCommand()
+ $check.CommandText = 'SELECT COUNT(*) FROM [ProtectedSecret] WHERE [Id]=?'
+ $null = $check.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100)
+ $check.Parameters[0].Value = $Context.Settings['SMTP_SECRET_ID']
+ $exists = [int]$check.ExecuteScalar() -gt 0
+ if ($exists) {
+ $command = $connection.CreateCommand()
+ $command.CommandText = 'UPDATE [ProtectedSecret] SET [DpapiCiphertext]=?, [RotatedAtUtc]=? WHERE [Id]=?'
+ $null = $command.Parameters.Add('ciphertext', [System.Data.OleDb.OleDbType]::LongVarBinary, $bytes.Length)
+ $command.Parameters[0].Value = $bytes
+ $null = $command.Parameters.Add('rotated', [System.Data.OleDb.OleDbType]::Date)
+ $command.Parameters[1].Value = [DateTime]::UtcNow
+ $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100)
+ $command.Parameters[2].Value = $Context.Settings['SMTP_SECRET_ID']
+ }
+ else {
+ $command = $connection.CreateCommand()
+ $command.CommandText = 'INSERT INTO [ProtectedSecret] ([Id],[Purpose],[DpapiCiphertext],[CreatedAtUtc]) VALUES (?,?,?,?)'
+ $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100)
+ $command.Parameters[0].Value = $Context.Settings['SMTP_SECRET_ID']
+ $null = $command.Parameters.Add('purpose', [System.Data.OleDb.OleDbType]::VarWChar, 50)
+ $command.Parameters[1].Value = 'smtp_password'
+ $null = $command.Parameters.Add('ciphertext', [System.Data.OleDb.OleDbType]::LongVarBinary, $bytes.Length)
+ $command.Parameters[2].Value = $bytes
+ $null = $command.Parameters.Add('created', [System.Data.OleDb.OleDbType]::Date)
+ $command.Parameters[3].Value = [DateTime]::UtcNow
+ }
+ $null = $command.ExecuteNonQuery()
+ }
+ finally {
+ $connection.Dispose()
+ [Array]::Clear($bytes, 0, $bytes.Length)
+ }
+}
+
+function Load-SmtpPassword {
+ param($Context)
+ $connection = Open-MailDatabase $Context
+ try {
+ $command = $connection.CreateCommand()
+ $command.CommandText = 'SELECT [DpapiCiphertext] FROM [ProtectedSecret] WHERE [Id]=? AND [Purpose]=?'
+ $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100)
+ $command.Parameters[0].Value = $Context.Settings['SMTP_SECRET_ID']
+ $null = $command.Parameters.Add('purpose', [System.Data.OleDb.OleDbType]::VarWChar, 50)
+ $command.Parameters[1].Value = 'smtp_password'
+ $bytes = $command.ExecuteScalar()
+ if ($null -eq $bytes -or $bytes -is [DBNull]) { throw 'SMTP password is not configured' }
+ $encrypted = [System.Text.Encoding]::UTF8.GetString($bytes)
+ return ConvertTo-SecureString -String $encrypted
+ }
+ finally {
+ $connection.Dispose()
+ }
+}
diff --git a/tools/set-smtp-password.ps1 b/tools/set-smtp-password.ps1
new file mode 100644
index 0000000..2527ca9
--- /dev/null
+++ b/tools/set-smtp-password.ps1
@@ -0,0 +1,13 @@
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot 'mail-common.ps1')
+
+$context = Get-MailContext
+$password = Read-Host 'Enter SMTP password (stored with Windows user protection)' -AsSecureString
+if ($password.Length -eq 0) { throw 'Empty SMTP password was not saved' }
+try {
+ Save-SmtpPassword -Context $context -Password $password
+ Write-Output 'SMTP password saved to the local MDB for this Windows user.'
+}
+finally {
+ $password.Dispose()
+}
diff --git a/tools/test-contact-flow.js b/tools/test-contact-flow.js
new file mode 100644
index 0000000..594777f
--- /dev/null
+++ b/tools/test-contact-flow.js
@@ -0,0 +1,59 @@
+// Runs the HTA contact workflow against a disposable MDB, with a small DOM stub.
+var fs = new ActiveXObject("Scripting.FileSystemObject");
+var root = fs.GetParentFolderName(fs.GetParentFolderName(WScript.ScriptFullName));
+var dbPath = WScript.Arguments(0);
+var csvPath = WScript.Arguments(1);
+var elements = {};
+var window = { location: { pathname: "/" + fs.BuildPath(root, "MassMailer.hta").replace(/\\/g, "/") } };
+var document = {
+ getElementById: function (id) {
+ if (!elements[id]) elements[id] = { innerText: "", value: "", disabled: false,
+ firstChild: null, appendChild: function () {}, removeChild: function () {} };
+ return elements[id];
+ },
+ createElement: function () { return { innerText: "", className: "", appendChild: function () {} }; }
+};
+
+function assert(condition, message) {
+ if (!condition) throw new Error(message);
+}
+
+try {
+ eval(fs.OpenTextFile(fs.BuildPath(root, "src\\contacts.js"), 1).ReadAll());
+ var html = fs.OpenTextFile(fs.BuildPath(root, "MassMailer.hta"), 1).ReadAll();
+ var match = /