| @@ -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 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 | ## 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 | | | 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. | | | `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**. | | | `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. | | | `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. | `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 | ## Open technical decisions | ||||
| | Area | Decision to prove in Sprint 0 | | | 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. | | | 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. | | | 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 | ## 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 | - 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 | - 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/ | - 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 | |||||
| @@ -13,7 +13,7 @@ | |||||
| 7. Record attempt, provider response, accepted/failed/uncertain state, timestamps, and campaign summary. Provider acceptance is not proof of delivery. | 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. | 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. | 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. | 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. | 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. | ||||
| @@ -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. | |||||
| @@ -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. | | | 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. | | | 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. | | | 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 | ## 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. | 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. | 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. | 4. Operator receives deployment, backup/restore, pause, reconciliation, and incident instructions. | ||||
| @@ -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. | - 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. | - 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. | - 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 | ## Working assumptions to validate | ||||
| @@ -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). | |||||
| @@ -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> | |||||
| @@ -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) | - [Project brief and open decisions](.specs/project/PROJECT.md) | ||||
| - [Requirements, Scrum stories, and sprint outline](.specs/features/mass-mailer/spec.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) | - [Example SMTP configuration without a password](config.example.ini) | ||||
| - [Access provider probe for 32-bit and 64-bit hosts](tools/probe-mdb-providers.vbs) | - [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. | |||||
| @@ -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 | |||||
| ) | |||||
| @@ -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 | # The SMTP password is configured through a setup action and stored as a | ||||
| # DPAPI-protected value in the database. Never add SMTP_PASSWORD here. | # DPAPI-protected value in the database. Never add SMTP_PASSWORD here. | ||||
| SMTP_HOST=netsol-smtp-oxcs.hostingplatform.com | SMTP_HOST=netsol-smtp-oxcs.hostingplatform.com | ||||
| @@ -8,6 +8,7 @@ SMTP_TLS=on | |||||
| SMTP_STARTTLS=on | SMTP_STARTTLS=on | ||||
| SMTP_FROM=danielcovington@postcardmojo.com | SMTP_FROM=danielcovington@postcardmojo.com | ||||
| SMTP_REPLY_TO=danielcovington@postcardmojo.com | SMTP_REPLY_TO=danielcovington@postcardmojo.com | ||||
| SMTP_TEST_TO= | |||||
| SMTP_SECRET_ID=primary_smtp | SMTP_SECRET_ID=primary_smtp | ||||
| DB_PATH=data/MassMailer.mdb | DB_PATH=data/MassMailer.mdb | ||||
| DB_PROVIDER=Microsoft.ACE.OLEDB.12.0 | 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 | # 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. | # 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. | # 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, | # 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 listed ACE provider passed a 32-bit .mdb create/write/read probe. | ||||
| # The image and message caps are initial application limits to validate | # The image and message caps are initial application limits to validate | ||||
| @@ -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, ""); | |||||
| } | |||||
| }; | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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 | |||||
| @@ -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() | |||||
| } | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| @@ -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 } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| @@ -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' | |||||
| @@ -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() | |||||
| } | |||||
| @@ -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() } | |||||
| } | |||||
Powered by TurnKey Linux.