Sfoglia il codice sorgente

Add Access-backed HTA foundation and safe contact workflow

master
Daniel Covington 1 giorno fa
parent
commit
50727719c2
22 ha cambiato i file con 1331 aggiunte e 12 eliminazioni
  1. +7
    -5
      .specs/features/mass-mailer/design.md
  2. +1
    -1
      .specs/features/mass-mailer/spec.md
  3. +18
    -0
      .specs/features/mass-mailer/tasks.md
  4. +4
    -1
      .specs/features/mass-mailer/test-plan.md
  5. +1
    -1
      .specs/project/PROJECT.md
  6. +19
    -0
      .specs/project/STATE.md
  7. +345
    -0
      MassMailer.hta
  8. +29
    -3
      README.md
  9. +7
    -0
      Start-MassMailer.cmd
  10. +3
    -1
      config.example.ini
  11. +93
    -0
      src/contacts.js
  12. +134
    -0
      tools/common.vbs
  13. +106
    -0
      tools/init-db.vbs
  14. +107
    -0
      tools/launch.vbs
  15. +111
    -0
      tools/mail-common.ps1
  16. +13
    -0
      tools/set-smtp-password.ps1
  17. +59
    -0
      tools/test-contact-flow.js
  18. +54
    -0
      tools/test-contact-flow.ps1
  19. +43
    -0
      tools/test-contacts.js
  20. +25
    -0
      tools/test-secret-store.ps1
  21. +67
    -0
      tools/test-smtp-capabilities.ps1
  22. +85
    -0
      tools/test-smtp-message.ps1

+ 7
- 5
.specs/features/mass-mailer/design.md Vedi File

@@ -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

+ 1
- 1
.specs/features/mass-mailer/spec.md Vedi File

@@ -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.



+ 18
- 0
.specs/features/mass-mailer/tasks.md Vedi File

@@ -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.

+ 4
- 1
.specs/features/mass-mailer/test-plan.md Vedi File

@@ -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.


+ 1
- 1
.specs/project/PROJECT.md Vedi File

@@ -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



+ 19
- 0
.specs/project/STATE.md Vedi File

@@ -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).

+ 345
- 0
MassMailer.hta Vedi File

@@ -0,0 +1,345 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="X-UA-Compatible" content="IE=9" />
<title>MassMailer</title>
<HTA:APPLICATION ID="MassMailer" APPLICATIONNAME="MassMailer" BORDER="thick" SCROLL="yes" SINGLEINSTANCE="yes" />
<style type="text/css">
body { margin: 0; font: 15px Segoe UI, Arial, sans-serif; color: #243145; background: #f4f6f8; }
header { background: #17324e; color: #fff; padding: 22px 28px; }
h1 { margin: 0; font-size: 24px; }
header p { margin: 7px 0 0; color: #d2dfec; }
main { padding: 24px 28px; }
.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; }
.number { display: block; margin-top: 8px; font-size: 28px; color: #17324e; }
.details { background: white; padding: 18px; border: 1px solid #dbe1e7; line-height: 1.7; }
.details strong { display: inline-block; width: 135px; }
button { background: #176b9b; color: white; border: 0; padding: 9px 16px; cursor: pointer; margin-top: 17px; }
button:disabled { background: #a7b3bd; cursor: default; }
.panel { background: white; padding: 18px; border: 1px solid #dbe1e7; margin-top: 18px; }
.panel h2 { margin: 0 0 12px; font-size: 19px; }
.field { display: block; margin: 12px 0; }
.field input, .field select { display: block; width: 95%; max-width: 640px; padding: 7px; margin-top: 4px; }
table { border-collapse: collapse; width: 100%; margin-top: 15px; }
th, td { border-bottom: 1px solid #e4e8ec; padding: 7px; text-align: left; }
.bad { color: #9c2929; }
.good { color: #235e37; }
#error { color: #9c2929; white-space: pre-wrap; margin-top: 12px; }
</style>
<script language="JScript" src="src/contacts.js"></script>
<script language="JScript" type="text/javascript">
var pendingCsvText = null;
var pendingPreview = null;
var pendingSource = "";
var pendingPath = "";

function projectRoot() {
var path = decodeURIComponent(window.location.pathname);
if (path.charAt(0) === "/" && path.charAt(2) === ":") path = path.substring(1);
path = path.replace(/\//g, "\\");
return new ActiveXObject("Scripting.FileSystemObject").GetParentFolderName(path);
}
function readSettings(filePath) {
var stream = new ActiveXObject("ADODB.Stream");
stream.Type = 2;
stream.Charset = "utf-8";
stream.Open();
stream.LoadFromFile(filePath);
var data = stream.ReadText();
stream.Close();
var result = {};
var lines = data.replace(/\r\n?/g, "\n").split("\n");
for (var i = 0; i < lines.length; i++) {
var line = lines[i].replace(/^\s+|\s+$/g, "");
if (!line || line.charAt(0) === "#" || line.charAt(0) === ";") continue;
var equalAt = line.indexOf("=");
if (equalAt > 0) result[line.substring(0, equalAt).replace(/\s/g, "").toUpperCase()] =
line.substring(equalAt + 1).replace(/^\s+|\s+$/g, "");
}
return result;
}
function countRows(db, tableName) {
var rows = db.Execute("SELECT COUNT(*) AS N FROM [" + tableName + "]");
var result = rows.Fields("N").Value;
rows.Close();
return result;
}
function openDatabase() {
var fs = new ActiveXObject("Scripting.FileSystemObject");
var root = projectRoot();
var config = readSettings(fs.BuildPath(root, "config.ini"));
var dbPath = config.DB_PATH;
if (fs.GetDriveName(dbPath) === "") dbPath = fs.BuildPath(root, dbPath);
dbPath = fs.GetAbsolutePathName(dbPath);
var db = new ActiveXObject("ADODB.Connection");
db.Open("Provider=" + config.DB_PROVIDER + ";Data Source=" + dbPath + ";");
return { db: db, config: config, path: dbPath };
}
function refreshStatus() {
var db = null;
try {
var opened = openDatabase();
db = opened.db;
document.getElementById("contacts").innerText = countRows(db, "Contact");
document.getElementById("suppressed").innerText = countRows(db, "Suppression");
document.getElementById("templates").innerText = countRows(db, "TemplateVersion");
document.getElementById("campaigns").innerText = countRows(db, "Campaign");
document.getElementById("database").innerText = opened.path;
document.getElementById("provider").innerText = opened.config.DB_PROVIDER;
document.getElementById("interval").innerText = opened.config.QUEUE_INTERVAL + " seconds";
document.getElementById("testTo").innerText = opened.config.SMTP_TEST_TO || "Not configured";
document.getElementById("error").innerText = "";
} catch (error) {
document.getElementById("error").innerText = "Status refresh failed: " + error.message;
} finally {
if (db !== null && db.State === 1) db.Close();
}
}
function readCsvFile(path) {
var fs = new ActiveXObject("Scripting.FileSystemObject");
if (!fs.FileExists(path)) throw new Error("CSV file not found: " + path);
if (fs.GetFile(path).Size > 2097152) throw new Error("CSV exceeds the 2 MB import limit");
var stream = new ActiveXObject("ADODB.Stream");
stream.Type = 2;
stream.Charset = "utf-8";
stream.Open();
stream.LoadFromFile(path);
var text = stream.ReadText();
stream.Close();
return text;
}
function emailLookup(db, tableName) {
var found = {};
var rows = db.Execute("SELECT [EmailKey] FROM [" + tableName + "]");
while (!rows.EOF) {
found[String(rows.Fields("EmailKey").Value).toLowerCase()] = true;
rows.MoveNext();
}
rows.Close();
return found;
}
function renderPreview(preview) {
document.getElementById("previewCount").innerText = preview.accepted.length +
" ready, " + preview.rejected.length + " rejected";
var body = document.getElementById("previewBody");
while (body.firstChild) body.removeChild(body.firstChild);
var items = preview.accepted.concat(preview.rejected);
items.sort(function (a, b) { return a.line - b.line; });
for (var i = 0; i < items.length; i++) {
var tr = document.createElement("tr");
var values = [items[i].line, items[i].email, items[i].businessName,
items[i].source, items[i].reason || "Ready"];
for (var j = 0; j < values.length; j++) {
var td = document.createElement("td");
td.innerText = values[j];
if (j === 4) td.className = items[i].reason ? "bad" : "good";
tr.appendChild(td);
}
body.appendChild(tr);
}
document.getElementById("commitImport").disabled = preview.accepted.length === 0;
}
function previewContacts() {
var db = null;
document.getElementById("importMessage").innerText = "";
document.getElementById("commitImport").disabled = true;
try {
var path = document.getElementById("csvPath").value;
pendingPath = path;
pendingSource = document.getElementById("sourceFallback").value.replace(/^\s+|\s+$/g, "");
pendingCsvText = readCsvFile(path);
var opened = openDatabase();
db = opened.db;
pendingPreview = MassMailerContacts.preview(pendingCsvText, pendingSource,
emailLookup(db, "Contact"), emailLookup(db, "Suppression"));
renderPreview(pendingPreview);
} catch (error) {
pendingPreview = null;
document.getElementById("importMessage").innerText = error.message;
} finally {
if (db !== null && db.State === 1) db.Close();
}
}
function utcNowSql() {
return "DateAdd('n'," + new Date().getTimezoneOffset() + ",Now())";
}
function importOne(db, row) {
var dateSql = utcNowSql();
if (row.acquiredAt) {
dateSql = "DateSerial(" + Number(row.acquiredAt.substring(0, 4)) + "," +
Number(row.acquiredAt.substring(5, 7)) + "," +
Number(row.acquiredAt.substring(8, 10)) + ")";
}
var cmd = new ActiveXObject("ADODB.Command");
cmd.ActiveConnection = db;
cmd.CommandText = "INSERT INTO [Contact] ([EmailOriginal],[EmailKey],[BusinessName]," +
"[SourceReference],[AcquiredAtUtc],[Country],[EligibilityBasis],[CreatedAtUtc]) " +
"VALUES (?,?,?,? ," + dateSql + ",'US','public_listing'," + utcNowSql() + ")";
cmd.Parameters.Append(cmd.CreateParameter("email", 202, 1, 254, row.email));
cmd.Parameters.Append(cmd.CreateParameter("key", 202, 1, 254, row.emailKey));
cmd.Parameters.Append(cmd.CreateParameter("business", 202, 1, 200, row.businessName));
cmd.Parameters.Append(cmd.CreateParameter("source", 203, 1, Math.max(1, row.source.length), row.source));
cmd.Execute();
}
function commitContacts() {
if (!pendingPreview || !pendingPreview.accepted.length) return;
var db = null, inTransaction = false, succeeded = false;
try {
if (document.getElementById("csvPath").value !== pendingPath)
throw new Error("CSV file selection changed. Preview it again.");
if (document.getElementById("sourceFallback").value.replace(/^\s+|\s+$/g, "") !== pendingSource)
throw new Error("Source reference changed. Preview the CSV again.");
var opened = openDatabase();
db = opened.db;
var current = MassMailerContacts.preview(pendingCsvText, pendingSource,
emailLookup(db, "Contact"), emailLookup(db, "Suppression"));
if (current.accepted.length !== pendingPreview.accepted.length ||
current.rejected.length !== pendingPreview.rejected.length)
throw new Error("Contacts or suppressions changed. Preview the CSV again.");
for (var check = 0; check < current.accepted.length; check++) {
if (current.accepted[check].emailKey !== pendingPreview.accepted[check].emailKey)
throw new Error("Contacts changed. Preview the CSV again.");
}
db.BeginTrans();
inTransaction = true;
for (var i = 0; i < current.accepted.length; i++) importOne(db, current.accepted[i]);
db.CommitTrans();
inTransaction = false;
document.getElementById("importMessage").innerText =
"Imported " + current.accepted.length + " contacts. Rejected rows were not saved.";
pendingPreview = null;
document.getElementById("commitImport").disabled = true;
succeeded = true;
} catch (error) {
if (inTransaction) db.RollbackTrans();
document.getElementById("importMessage").innerText = "Import failed: " + error.message;
} finally {
if (db !== null && db.State === 1) db.Close();
if (succeeded) refreshStatus();
}
}
function addSuppression() {
var db = null, succeeded = false;
try {
var email = document.getElementById("suppressEmail").value.replace(/^\s+|\s+$/g, "");
if (!MassMailerContacts.isEmail(email)) throw new Error("Enter a valid email address");
var key = MassMailerContacts.emailKey(email);
var reason = document.getElementById("suppressReason").value;
var evidence = document.getElementById("suppressEvidence").value.replace(/^\s+|\s+$/g, "");
var opened = openDatabase();
db = opened.db;
if (emailLookup(db, "Suppression")[key]) throw new Error("Address is already suppressed");
var cmd = new ActiveXObject("ADODB.Command");
cmd.ActiveConnection = db;
cmd.CommandText = "INSERT INTO [Suppression] ([EmailKey],[Reason],[RecordedAtUtc]," +
"[EvidenceReference]) VALUES (?, ?, " + utcNowSql() + ", ?)";
cmd.Parameters.Append(cmd.CreateParameter("email", 202, 1, 254, key));
cmd.Parameters.Append(cmd.CreateParameter("reason", 202, 1, 40, reason));
cmd.Parameters.Append(cmd.CreateParameter("evidence", 203, 1, Math.max(1, evidence.length), evidence));
cmd.Execute();
document.getElementById("suppressMessage").innerText = "Suppressed " + key;
document.getElementById("suppressEmail").value = "";
succeeded = true;
} catch (error) {
document.getElementById("suppressMessage").innerText = "Suppression failed: " + error.message;
} finally {
if (db !== null && db.State === 1) db.Close();
if (succeeded) refreshStatus();
}
}
function powerShellHost() {
var fs = new ActiveXObject("Scripting.FileSystemObject");
var shell = new ActiveXObject("WScript.Shell");
var root = shell.ExpandEnvironmentStrings("%SystemRoot%");
var settings = readSettings(fs.BuildPath(projectRoot(), "config.ini"));
var folder = (settings.DB_BITNESS || "").toLowerCase() === "x64" ? "System32" :
(fs.FolderExists(root + "\\SysWOW64") ? "SysWOW64" : "System32");
return root + "\\" + folder + "\\WindowsPowerShell\\v1.0\\powershell.exe";
}
function quoted(value) { return '"' + value + '"'; }
function configurePassword() {
try {
var fs = new ActiveXObject("Scripting.FileSystemObject");
var script = fs.BuildPath(projectRoot(), "tools\\set-smtp-password.ps1");
var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " + quoted(script);
var status = new ActiveXObject("WScript.Shell").Run(command, 1, true);
document.getElementById("transportMessage").innerText = status === 0 ?
"SMTP password setup completed." : "SMTP password setup failed. Review the console error.";
} catch (error) {
document.getElementById("transportMessage").innerText = "Setup failed: " + error.message;
}
}
function sendInternalTest() {
try {
var fs = new ActiveXObject("Scripting.FileSystemObject");
var root = projectRoot();
var settings = readSettings(fs.BuildPath(root, "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;
} catch (error) {
document.getElementById("transportMessage").innerText = "Test failed: " + error.message;
}
}
</script>
</head>
<body onload="refreshStatus()">
<header><h1>MassMailer</h1><p>9 x 12 postcard marketing campaign workspace</p></header>
<main>
<div class="notice"><strong>Campaign sending is disabled in this build.</strong> The SMTP test sends one message only when you press its button.</div>
<div class="cards">
<div class="card">Contacts<span class="number" id="contacts">—</span></div>
<div class="card">Suppressed<span class="number" id="suppressed">—</span></div>
<div class="card">Templates<span class="number" id="templates">—</span></div>
<div class="card">Campaigns<span class="number" id="campaigns">—</span></div>
</div>
<div class="details">
<div><strong>Database</strong><span id="database">Checking…</span></div>
<div><strong>Provider</strong><span id="provider">Checking…</span></div>
<div><strong>Send interval</strong><span id="interval">Checking…</span></div>
<div><strong>Internal test to</strong><span id="testTo">Checking…</span></div>
<div><strong>Send state</strong>Disabled</div>
<button onclick="refreshStatus()">Refresh status</button>
<div id="error"></div>
</div>
<div class="panel">
<h2>SMTP setup and internal test</h2>
<p>The password entry opens a local console and stores a protected value in the MDB. The test sends one HTML message with an inline image to the configured test inbox.</p>
<button onclick="configurePassword()">Set or rotate SMTP password</button>
<button onclick="sendInternalTest()">Send one internal test</button>
<div id="transportMessage"></div>
</div>
<div class="panel">
<h2>Import business contacts</h2>
<p>UTF-8 CSV needs an <code>email</code> header. Optional headers: <code>business_name</code>, <code>source_reference</code>, and <code>acquired_at</code> (YYYY-MM-DD). Every accepted row needs a source reference.</p>
<label class="field">CSV file <input id="csvPath" type="file" accept=".csv" /></label>
<label class="field">Source URL or reference for rows without one
<input id="sourceFallback" type="text" maxlength="2048" /></label>
<button onclick="previewContacts()">Preview import</button>
<button id="commitImport" onclick="commitContacts()" disabled="disabled">Import ready contacts</button>
<div id="importMessage"></div>
<p id="previewCount"></p>
<table><thead><tr><th>CSV row</th><th>Email</th><th>Business</th><th>Source</th><th>Result</th></tr></thead>
<tbody id="previewBody"></tbody></table>
</div>
<div class="panel">
<h2>Suppress an address</h2>
<p>Add unsubscribe replies here before the next send. Suppression applies across all campaigns.</p>
<label class="field">Email address <input id="suppressEmail" type="text" maxlength="254" /></label>
<label class="field">Reason <select id="suppressReason"><option value="opt-out">Opt-out reply</option><option value="manual">Manual</option><option value="bounce">Hard bounce</option><option value="complaint">Complaint</option></select></label>
<label class="field">Evidence or note <input id="suppressEvidence" type="text" maxlength="2048" /></label>
<button onclick="addSuppression()">Suppress address</button>
<div id="suppressMessage"></div>
</div>
</main>
</body>
</html>

+ 29
- 3
README.md Vedi File

@@ -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.

+ 7
- 0
Start-MassMailer.cmd Vedi File

@@ -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
)

+ 3
- 1
config.example.ini Vedi File

@@ -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


+ 93
- 0
src/contacts.js Vedi File

@@ -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, "");
}
};

+ 134
- 0
tools/common.vbs Vedi File

@@ -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

+ 106
- 0
tools/init-db.vbs Vedi File

@@ -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

+ 107
- 0
tools/launch.vbs Vedi File

@@ -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

+ 111
- 0
tools/mail-common.ps1 Vedi File

@@ -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()
}
}

+ 13
- 0
tools/set-smtp-password.ps1 Vedi File

@@ -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()
}

+ 59
- 0
tools/test-contact-flow.js Vedi File

@@ -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 = /<script language="JScript" type="text\/javascript">([\s\S]*?)<\/script>/i.exec(html);
assert(match !== null, "HTA script block missing");
eval(match[1]);
readSettings = function () {
return { DB_PATH: dbPath, DB_PROVIDER: "Microsoft.ACE.OLEDB.12.0",
DB_BITNESS: "x86", QUEUE_INTERVAL: "300" };
};
assert(fs.FileExists(powerShellHost()), "Configured PowerShell host is missing");

elements.suppressEmail = { value: "blocked@example.invalid" };
elements.suppressReason = { value: "opt-out" };
elements.suppressEvidence = { value: "reply mailbox" };
addSuppression();
assert(elements.suppressMessage.innerText.indexOf("Suppressed") === 0,
"Suppression failed: " + elements.suppressMessage.innerText);

elements.csvPath = { value: csvPath };
elements.sourceFallback = { value: "public business directory" };
previewContacts();
assert(pendingPreview !== null, "Preview failed: " + elements.importMessage.innerText);
assert(pendingPreview.accepted.length === 2, "Expected two ready contacts");
assert(pendingPreview.rejected.length === 2, "Expected duplicate and suppressed rejections");
commitContacts();
assert(elements.importMessage.innerText.indexOf("Imported 2") === 0,
"Import failed: " + elements.importMessage.innerText);
assert(elements.contacts.innerText == 2, "Contact count is not two");
assert(elements.suppressed.innerText == 1, "Suppression count is not one");

previewContacts();
assert(pendingPreview.accepted.length === 0, "Re-import offered an existing contact");
assert(pendingPreview.rejected.length === 4, "Re-import rejection count wrong");
WScript.Echo("Contact import and suppression integration passed");
} catch (error) {
WScript.Echo("FAIL: " + error.message);
WScript.Quit(1);
}

+ 54
- 0
tools/test-contact-flow.ps1 Vedi File

@@ -0,0 +1,54 @@
$ErrorActionPreference = 'Stop'
$projectRoot = Split-Path -Parent $PSScriptRoot
$workPath = Join-Path $projectRoot ('temp\contact-flow-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $workPath -Force | Out-Null
$databasePath = Join-Path $workPath 'test.mdb'
$configPath = Join-Path $workPath 'test.ini'
$csvPath = Join-Path $workPath 'contacts.csv'
$pickupPath = Join-Path $workPath 'pickup'
$scriptHost = Join-Path $env:SystemRoot 'SysWOW64\cscript.exe'
$powerShellX86 = Join-Path $env:SystemRoot 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe'

try {
$example = Get-Content -LiteralPath (Join-Path $projectRoot 'config.example.ini') -Raw
$example.Replace('DB_PATH=data/MassMailer.mdb', ('DB_PATH=' + $databasePath)).Replace(
'SMTP_TEST_TO=', 'SMTP_TEST_TO=test@example.invalid') |
Set-Content -LiteralPath $configPath -Encoding UTF8
@'
email,business_name,source_reference,acquired_at
one@example.invalid,"Example, Inc.",https://example.invalid/one,2026-10-20
two@example.invalid,Two,,
ONE@example.invalid,Duplicate,https://example.invalid/one,
blocked@example.invalid,Blocked,https://example.invalid/blocked,
'@ | Set-Content -LiteralPath $csvPath -Encoding UTF8

& $scriptHost //nologo (Join-Path $PSScriptRoot 'init-db.vbs') $configPath
if ($LASTEXITCODE -ne 0) { throw 'MDB initialization failed' }
& $scriptHost //nologo (Join-Path $PSScriptRoot 'launch.vbs') check $configPath
if ($LASTEXITCODE -ne 0) { throw 'Startup preflight failed' }
& $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'test-secret-store.ps1') -ConfigPath $configPath
if ($LASTEXITCODE -ne 0) { throw 'Protected password test failed' }
New-Item -ItemType Directory -Path $pickupPath | Out-Null
& $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'test-smtp-message.ps1') -ConfigPath $configPath -PickupDirectory $pickupPath
if ($LASTEXITCODE -ne 0) { throw 'Pickup MIME test failed' }
$messages = @(Get-ChildItem -LiteralPath $pickupPath -Filter '*.eml' -File)
if ($messages.Count -ne 1) { throw 'Pickup MIME test did not create one message' }
$mime = Get-Content -LiteralPath $messages[0].FullName -Raw
if ($mime -notmatch 'multipart/related' -or $mime -notmatch 'Content-ID:\s*<massmailer-test-image>') {
throw 'Pickup MIME is missing a related inline image'
}
Write-Output 'HTML and inline-image MIME pickup test passed'
& $scriptHost //nologo //E:JScript (Join-Path $PSScriptRoot 'test-contact-flow.js') $databasePath $csvPath
if ($LASTEXITCODE -ne 0) { throw 'Contact flow test failed' }
}
finally {
if (Test-Path -LiteralPath $pickupPath) {
Get-ChildItem -LiteralPath $pickupPath -File | ForEach-Object { Remove-Item -LiteralPath $_.FullName -Force }
Remove-Item -LiteralPath $pickupPath -Force
}
foreach ($fileName in @('test.ini', 'contacts.csv', 'test.mdb', 'test.ldb')) {
$filePath = Join-Path $workPath $fileName
if (Test-Path -LiteralPath $filePath) { Remove-Item -LiteralPath $filePath -Force }
}
if (Test-Path -LiteralPath $workPath) { Remove-Item -LiteralPath $workPath -Force }
}

+ 43
- 0
tools/test-contacts.js Vedi File

@@ -0,0 +1,43 @@
var fs = new ActiveXObject("Scripting.FileSystemObject");
var root = fs.GetParentFolderName(fs.GetParentFolderName(WScript.ScriptFullName));
eval(fs.OpenTextFile(fs.BuildPath(root, "src\\contacts.js"), 1).ReadAll());

function assert(condition, message) {
if (!condition) throw new Error(message);
}

try {
var csv = "\ufeffemail,business_name,source_reference,acquired_at\r\n" +
'one@example.com,"Acme, Inc.",https://example.com/acme,2026-10-21\r\n' +
'ONE@example.com,Duplicate,https://example.com/acme,\r\n' +
'blocked@example.com,Blocked,https://example.com/blocked,\r\n' +
'bad address,Bad,https://example.com/bad,\r\n' +
'two@example.com,"Two\nLines",https://example.com/two,\r\n';
var result = MassMailerContacts.preview(csv, "", {}, { "blocked@example.com": true });
assert(result.accepted.length === 2, "Expected two accepted rows");
assert(result.rejected.length === 3, "Expected three rejected rows");
assert(result.accepted[0].businessName === "Acme, Inc.", "Quoted comma lost");
assert(result.accepted[1].businessName === "Two\nLines", "Quoted newline lost");
assert(result.rejected[0].reason === "Duplicate in CSV", "Duplicate reason wrong");
assert(result.rejected[1].reason === "Suppressed address", "Suppression reason wrong");
assert(result.rejected[2].reason === "Invalid email", "Invalid email reason wrong");

var fallback = MassMailerContacts.preview("email\na@example.com", "public directory", {}, {});
assert(fallback.accepted.length === 1, "Fallback source should allow email-only CSV");
assert(fallback.accepted[0].source === "public directory", "Fallback source missing");

var missingSource = MassMailerContacts.preview("email\na@example.com", "", {}, {});
assert(missingSource.rejected[0].reason === "Missing source reference", "Missing source not flagged");

assert(!MassMailerContacts.isoDate("2026-02-30"), "Invalid date accepted");
try {
MassMailerContacts.parseCsv('email\n"unclosed');
throw new Error("Unclosed CSV quote was accepted");
} catch (error) {
if (error.message === "Unclosed CSV quote was accepted") throw error;
}
WScript.Echo("Contact parser tests passed");
} catch (error) {
WScript.Echo("FAIL: " + error.message);
WScript.Quit(1);
}

+ 25
- 0
tools/test-secret-store.ps1 Vedi File

@@ -0,0 +1,25 @@
param([Parameter(Mandatory = $true)][string]$ConfigPath)

$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'mail-common.ps1')
$context = Get-MailContext -ConfigPath $ConfigPath

foreach ($testNumber in 1, 2) {
$synthetic = [guid]::NewGuid().ToString('N')
$password = ConvertTo-SecureString -String $synthetic -AsPlainText -Force
try {
Save-SmtpPassword -Context $context -Password $password
$loaded = Load-SmtpPassword -Context $context
try {
$credential = New-Object System.Net.NetworkCredential -ArgumentList @('test', $loaded)
if ($credential.Password -cne $synthetic) { throw 'Protected password round trip failed' }
}
finally {
$loaded.Dispose()
}
}
finally {
$password.Dispose()
}
}
Write-Output 'DPAPI password save, load, and rotation passed'

+ 67
- 0
tools/test-smtp-capabilities.ps1 Vedi File

@@ -0,0 +1,67 @@
param(
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini')
)

$ErrorActionPreference = 'Stop'
$settings = @{}
foreach ($line in Get-Content -LiteralPath $ConfigPath -Encoding UTF8) {
if ($line -match '^\s*([A-Z_]+)\s*=\s*(.*?)\s*$') {
$settings[$matches[1]] = $matches[2]
}
}
$smtpHost = $settings['SMTP_HOST']
$smtpPort = 0
if ([string]::IsNullOrWhiteSpace($smtpHost) -or
-not [int]::TryParse($settings['SMTP_PORT'], [ref]$smtpPort) -or
$smtpPort -lt 1 -or $smtpPort -gt 65535) {
throw 'Valid SMTP_HOST and SMTP_PORT are required in the config file'
}

function Read-Reply($reader) {
$lines = New-Object 'System.Collections.Generic.List[string]'
do {
$line = $reader.ReadLine()
if ($null -eq $line -or $line -notmatch '^\d{3}[- ]') {
throw "Invalid or incomplete SMTP reply: $line"
}
$lines.Add($line)
} while ($line.Substring(3, 1) -eq '-')
return ,$lines.ToArray()
}

$client = New-Object System.Net.Sockets.TcpClient
try {
$connect = $client.BeginConnect($smtpHost, $smtpPort, $null, $null)
if (-not $connect.AsyncWaitHandle.WaitOne(10000)) { throw 'SMTP connection timed out' }
$client.EndConnect($connect)
$client.ReceiveTimeout = 10000
$client.SendTimeout = 10000
$stream = $client.GetStream()
$reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::ASCII)
$writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII)
$writer.NewLine = "`r`n"
$writer.AutoFlush = $true

$greeting = Read-Reply $reader
if (-not $greeting[-1].StartsWith('220')) { throw "SMTP greeting rejected: $($greeting[-1])" }
$writer.WriteLine('EHLO localhost')
$ehlo = Read-Reply $reader
if (-not $ehlo[-1].StartsWith('250')) { throw "EHLO rejected: $($ehlo[-1])" }
if (-not ($ehlo | Where-Object { $_ -match '^250[- ]STARTTLS(?:\s|$)' })) {
throw 'Server did not advertise STARTTLS'
}
$writer.WriteLine('STARTTLS')
$startTls = Read-Reply $reader
if (-not $startTls[-1].StartsWith('220')) { throw "STARTTLS rejected: $($startTls[-1])" }

$secureStream = New-Object System.Net.Security.SslStream($stream, $false)
$secureStream.AuthenticateAsClient($smtpHost)
if (-not $secureStream.IsAuthenticated -or -not $secureStream.IsEncrypted) {
throw 'TLS negotiation did not establish an encrypted session'
}
Write-Output "SMTP STARTTLS OK: $smtpHost`:$smtpPort ($($secureStream.SslProtocol))"
$secureStream.Close()
}
finally {
$client.Close()
}

+ 85
- 0
tools/test-smtp-message.ps1 Vedi File

@@ -0,0 +1,85 @@
param(
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'),
[string]$PickupDirectory,
[switch]$Send
)

$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'mail-common.ps1')
if ([bool]$Send -eq [bool]$PickupDirectory) {
throw 'Choose exactly one mode: -Send or -PickupDirectory'
}

$context = Get-MailContext -ConfigPath $ConfigPath
$settings = $context.Settings
foreach ($name in @('SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_FROM', 'SMTP_REPLY_TO', 'SMTP_TEST_TO')) {
if (-not $settings.ContainsKey($name) -or -not $settings[$name]) { throw "Missing setting: $name" }
}
if ($settings['SMTP_TLS'] -ne 'on' -or $settings['SMTP_STARTTLS'] -ne 'on') {
throw 'SMTP_TLS and SMTP_STARTTLS must both be on'
}
$port = 0
if (-not [int]::TryParse($settings['SMTP_PORT'], [ref]$port) -or $port -lt 1 -or $port -gt 65535) {
throw 'Invalid SMTP_PORT'
}
$testTo = New-Object System.Net.Mail.MailAddress($settings['SMTP_TEST_TO'])
if ($testTo.Address -ne $settings['SMTP_TEST_TO']) { throw 'SMTP_TEST_TO must be a single bare email address' }

$client = $null
$message = $null
$password = $null
$imageStream = $null
try {
Add-Type -AssemblyName System.Drawing
$imageStream = New-Object System.IO.MemoryStream
$bitmap = New-Object System.Drawing.Bitmap(1, 1)
try {
$bitmap.SetPixel(0, 0, [System.Drawing.Color]::DarkBlue)
$bitmap.Save($imageStream, [System.Drawing.Imaging.ImageFormat]::Png)
}
finally { $bitmap.Dispose() }
$imageStream.Position = 0

$message = New-Object System.Net.Mail.MailMessage
$message.From = New-Object System.Net.Mail.MailAddress($settings['SMTP_FROM'])
$null = $message.ReplyToList.Add($settings['SMTP_REPLY_TO'])
$null = $message.To.Add($testTo)
$message.Subject = 'MassMailer internal SMTP transport test'
$message.SubjectEncoding = [System.Text.Encoding]::UTF8
$html = '<html><body><p>Internal transport test. No marketing campaign was sent.</p>' +
'<img src="cid:massmailer-test-image" width="1" height="1" alt="Test image"></body></html>'
$view = [System.Net.Mail.AlternateView]::CreateAlternateViewFromString(
$html, [System.Text.Encoding]::UTF8, 'text/html')
$image = New-Object System.Net.Mail.LinkedResource($imageStream, 'image/png')
$image.ContentId = 'massmailer-test-image'
$image.TransferEncoding = [System.Net.Mime.TransferEncoding]::Base64
$null = $view.LinkedResources.Add($image)
$null = $message.AlternateViews.Add($view)

$client = New-Object System.Net.Mail.SmtpClient($settings['SMTP_HOST'], $port)
$client.Timeout = 20000
if ($Send) {
$password = Load-SmtpPassword -Context $context
$client.UseDefaultCredentials = $false
$client.Credentials = New-Object System.Net.NetworkCredential -ArgumentList @($settings['SMTP_USER'], $password)
$client.EnableSsl = $true
$client.Send($message)
Write-Output "Internal SMTP test accepted for $($testTo.Address). Check that inbox and the received MIME."
}
else {
$pickupPath = [System.IO.Path]::GetFullPath($PickupDirectory)
if (-not (Test-Path -LiteralPath $pickupPath -PathType Container)) {
throw "Pickup directory not found: $pickupPath"
}
$client.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::SpecifiedPickupDirectory
$client.PickupDirectoryLocation = $pickupPath
$client.Send($message)
Write-Output "Test MIME written to pickup directory: $pickupPath"
}
}
finally {
if ($client) { $client.Dispose() }
if ($message) { $message.Dispose() }
if ($imageStream) { $imageStream.Dispose() }
if ($password) { $password.Dispose() }
}

Loading…
Annulla
Salva

Powered by TurnKey Linux.