| @@ -8,10 +8,10 @@ | |||||
| | F02 ✅ | MDB initializer with versioned schema | F01 | Fresh MDB, tables, unique indexes, and schema version 1 created in disposable integration test. | | | 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. | | | 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. | | | 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. | | |||||
| | F05 ✅ | SMTP transport and protected secret feasibility | F02 | STARTTLS 1.3, DPAPI round trip/rotation, and inline-image pickup MIME pass; owner reports the authenticated internal SMTP test worked. Detailed inbox image rendering is not separately recorded. | | |||||
| | F06 ✅ | Contact import and suppression controls | F02, F04 | Disposable MDB integration test passes import, duplicate rejection, re-import rejection, and suppression. | | | F06 ✅ | Contact import and suppression controls | F02, F04 | Disposable MDB integration test passes import, duplicate rejection, re-import rejection, and suppression. | | ||||
| | F07 ✅ | HTML template import and safe preview | F02, F04 | Disposable MDB test passes image snapshot and preview after deleting source; unsafe paths, active content, and CSS image URLs fail. Preview does not yet include campaign footer. | | | F07 ✅ | HTML template import and safe preview | F02, F04 | Disposable MDB test passes image snapshot and preview after deleting source; unsafe paths, active content, and CSS image URLs fail. Preview does not yet include campaign footer. | | ||||
| | F08 | Campaign approval and test send | F05-F07 | Snapshot recipient/content/identity; explicit approval and internal-only test send. | | |||||
| | F08 ◐ | Campaign approval and test send | F05-F07 | Disposable MDB test passes full footer preview, suppression-sensitive review token, internal-only MIME, and paused approval. Real postal address and campaign-specific inbox test pending. | | |||||
| | F09 | In-app dispatch, pacing, pause, and recovery | F08 | Fake adapter tests cover gap, close/reopen, suppression, and uncertain outcomes. | | | 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. | | | F10 | Reporting, backup, and pilot readiness | F09 | Export, restore drill, and manual pilot checklist pass. | | ||||
| @@ -45,6 +45,8 @@ | |||||
| ## Release gates | ## Release gates | ||||
| On 2026-10-22, the owner reported that password entry and the one-message authenticated SMTP transport test worked. The specific received-image rendering result was not captured. The campaign-specific test and live dispatch gates remain open. | |||||
| 1. All P1 stories and T01-T32 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. | ||||
| @@ -2,7 +2,7 @@ | |||||
| ## Current milestone | ## Current milestone | ||||
| F01-F04, F06, and F07 are implemented and locally verified. F07 imports constrained well-formed HTML and local image snapshots, with an isolated stored-content preview; the campaign footer belongs to F08. 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. | |||||
| F01-F07 are implemented and locally verified. The owner reports the authenticated internal SMTP test worked after entering the password; detailed received-image rendering is not separately recorded. F08 now has review, full footer preview, campaign-specific internal test, and paused approval. Its disposable MDB/MIME integration test passes; the real campaign-specific inbox test requires the physical postal address. Campaign sending remains unavailable. | |||||
| ## Decisions | ## Decisions | ||||
| @@ -14,6 +14,6 @@ F01-F04, F06, and F07 are implemented and locally verified. F07 imports constrai | |||||
| ## Next dependencies | ## 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 campaign approval and in-app dispatch (F08-F09), including a full message preview with the required footer. | |||||
| - Enter the business postal address in local `config.ini` before campaign review and approval. | |||||
| - Import the intended template, run its campaign-specific internal test, and inspect the received message before approving it. | |||||
| - Implement in-app dispatch, pacing, pause, recovery, and reporting (F09-F10) before any business-contact send. | |||||
| @@ -27,7 +27,7 @@ | |||||
| .bad { color: #9c2929; } | .bad { color: #9c2929; } | ||||
| .good { color: #235e37; } | .good { color: #235e37; } | ||||
| #error { color: #9c2929; white-space: pre-wrap; margin-top: 12px; } | #error { color: #9c2929; white-space: pre-wrap; margin-top: 12px; } | ||||
| #templatePreview { width: 100%; height: 440px; border: 1px solid #dbe1e7; background: white; margin-top: 12px; } | |||||
| #templatePreview, #campaignPreview { width: 100%; height: 440px; border: 1px solid #dbe1e7; background: white; margin-top: 12px; } | |||||
| </style> | </style> | ||||
| <script language="JScript" src="src/contacts.js"></script> | <script language="JScript" src="src/contacts.js"></script> | ||||
| <script language="JScript" type="text/javascript"> | <script language="JScript" type="text/javascript"> | ||||
| @@ -35,6 +35,8 @@ | |||||
| var pendingPreview = null; | var pendingPreview = null; | ||||
| var pendingSource = ""; | var pendingSource = ""; | ||||
| var pendingPath = ""; | var pendingPath = ""; | ||||
| var pendingCampaignReview = null; | |||||
| var campaignTestAccepted = false; | |||||
| function projectRoot() { | function projectRoot() { | ||||
| var path = decodeURIComponent(window.location.pathname); | var path = decodeURIComponent(window.location.pathname); | ||||
| @@ -93,6 +95,7 @@ | |||||
| document.getElementById("testTo").innerText = opened.config.SMTP_TEST_TO || "Not configured"; | document.getElementById("testTo").innerText = opened.config.SMTP_TEST_TO || "Not configured"; | ||||
| document.getElementById("error").innerText = ""; | document.getElementById("error").innerText = ""; | ||||
| refreshTemplates(); | refreshTemplates(); | ||||
| refreshCampaigns(); | |||||
| } catch (error) { | } catch (error) { | ||||
| document.getElementById("error").innerText = "Status refresh failed: " + error.message; | document.getElementById("error").innerText = "Status refresh failed: " + error.message; | ||||
| } finally { | } finally { | ||||
| @@ -298,16 +301,25 @@ | |||||
| db = openDatabase().db; | db = openDatabase().db; | ||||
| var rows = db.Execute("SELECT [Id],[TemplateName],[VersionNumber] FROM [TemplateVersion] WHERE [ValidationStatus]='validated' ORDER BY [Id] DESC"); | var rows = db.Execute("SELECT [Id],[TemplateName],[VersionNumber] FROM [TemplateVersion] WHERE [ValidationStatus]='validated' ORDER BY [Id] DESC"); | ||||
| var select = document.getElementById("templateVersion"); | var select = document.getElementById("templateVersion"); | ||||
| var campaignSelect = document.getElementById("campaignTemplateVersion"); | |||||
| while (select.options.length) select.remove(0); | while (select.options.length) select.remove(0); | ||||
| while (campaignSelect.options.length) campaignSelect.remove(0); | |||||
| while (!rows.EOF) { | while (!rows.EOF) { | ||||
| var option = document.createElement("option"); | |||||
| option.value = String(rows.Fields("Id").Value); | |||||
| option.text = String(rows.Fields("TemplateName").Value) + " — version " + | |||||
| var value = String(rows.Fields("Id").Value); | |||||
| var label = String(rows.Fields("TemplateName").Value) + " — version " + | |||||
| String(rows.Fields("VersionNumber").Value); | String(rows.Fields("VersionNumber").Value); | ||||
| var option = document.createElement("option"); | |||||
| option.value = value; | |||||
| option.text = label; | |||||
| select.add(option); | select.add(option); | ||||
| var campaignOption = document.createElement("option"); | |||||
| campaignOption.value = value; | |||||
| campaignOption.text = label; | |||||
| campaignSelect.add(campaignOption); | |||||
| rows.MoveNext(); | rows.MoveNext(); | ||||
| } | } | ||||
| rows.Close(); | rows.Close(); | ||||
| invalidateCampaignReview(); | |||||
| } catch (error) { | } catch (error) { | ||||
| document.getElementById("templateMessage").innerText = "Template list failed: " + error.message; | document.getElementById("templateMessage").innerText = "Template list failed: " + error.message; | ||||
| } finally { | } finally { | ||||
| @@ -349,6 +361,119 @@ | |||||
| document.getElementById("templateMessage").innerText = "Preview failed: " + error.message; | document.getElementById("templateMessage").innerText = "Preview failed: " + error.message; | ||||
| } | } | ||||
| } | } | ||||
| function refreshCampaigns() { | |||||
| var db = null; | |||||
| try { | |||||
| db = openDatabase().db; | |||||
| var rows = db.Execute("SELECT [Id],[Name],[Status] FROM [Campaign] ORDER BY [Id] DESC"); | |||||
| var body = document.getElementById("campaignBody"); | |||||
| while (body.firstChild) body.removeChild(body.firstChild); | |||||
| while (!rows.EOF) { | |||||
| var tr = document.createElement("tr"); | |||||
| var values = [rows.Fields("Id").Value, rows.Fields("Name").Value, | |||||
| rows.Fields("Status").Value]; | |||||
| for (var i = 0; i < values.length; i++) { | |||||
| var td = document.createElement("td"); | |||||
| td.innerText = String(values[i]); | |||||
| tr.appendChild(td); | |||||
| } | |||||
| body.appendChild(tr); | |||||
| rows.MoveNext(); | |||||
| } | |||||
| rows.Close(); | |||||
| } catch (error) { | |||||
| document.getElementById("campaignMessage").innerText = "Campaign list failed: " + error.message; | |||||
| } finally { | |||||
| if (db !== null && db.State === 1) db.Close(); | |||||
| } | |||||
| } | |||||
| function invalidateCampaignReview() { | |||||
| pendingCampaignReview = null; | |||||
| campaignTestAccepted = false; | |||||
| document.getElementById("sendCampaignTest").disabled = true; | |||||
| document.getElementById("approveCampaign").disabled = true; | |||||
| document.getElementById("confirmReceived").checked = false; | |||||
| document.getElementById("confirmReceived").disabled = true; | |||||
| document.getElementById("campaignReview").innerText = ""; | |||||
| document.getElementById("campaignPreview").src = "about:blank"; | |||||
| } | |||||
| function campaignRequest() { | |||||
| return { | |||||
| Name: document.getElementById("campaignName").value, | |||||
| Subject: document.getElementById("campaignSubject").value, | |||||
| TemplateId: document.getElementById("campaignTemplateVersion").value, | |||||
| ReviewToken: pendingCampaignReview ? pendingCampaignReview.Token : "", | |||||
| TestReceived: document.getElementById("confirmReceived").checked | |||||
| }; | |||||
| } | |||||
| function withCampaignRequest(scriptName, extraArgs) { | |||||
| var fs = new ActiveXObject("Scripting.FileSystemObject"); | |||||
| var folder = fs.BuildPath(projectRoot(), "temp"); | |||||
| if (!fs.FolderExists(folder)) fs.CreateFolder(folder); | |||||
| var path = fs.BuildPath(folder, fs.GetTempName()); | |||||
| var stream = new ActiveXObject("ADODB.Stream"); | |||||
| try { | |||||
| stream.Type = 2; | |||||
| stream.Charset = "utf-8"; | |||||
| stream.Open(); | |||||
| stream.WriteText(JSON.stringify(campaignRequest())); | |||||
| stream.SaveToFile(path, 2); | |||||
| stream.Close(); | |||||
| return runTemplateTool(scriptName, "-RequestPath " + quoted(path) + (extraArgs || "")); | |||||
| } finally { | |||||
| if (stream.State === 1) stream.Close(); | |||||
| if (fs.FileExists(path)) fs.DeleteFile(path); | |||||
| } | |||||
| } | |||||
| function reviewCampaign() { | |||||
| invalidateCampaignReview(); | |||||
| try { | |||||
| var data = JSON.parse(withCampaignRequest("review-campaign.ps1", "")); | |||||
| pendingCampaignReview = data; | |||||
| document.getElementById("campaignReview").innerText = | |||||
| data.Count + " eligible recipients. From: " + data.From + ". Reply-to: " + | |||||
| data.ReplyTo + ". Postal address: " + data.PostalAddress + | |||||
| ". Template: " + data.TemplateName + " v" + data.TemplateVersion + | |||||
| ". Subject: " + data.Subject + "."; | |||||
| document.getElementById("campaignPreview").src = data.PreviewPath; | |||||
| document.getElementById("sendCampaignTest").disabled = false; | |||||
| document.getElementById("campaignMessage").innerText = | |||||
| "Review the full message, then send it to the internal test inbox."; | |||||
| } catch (error) { | |||||
| document.getElementById("campaignMessage").innerText = "Review failed: " + error.message; | |||||
| } | |||||
| } | |||||
| function sendReviewedCampaignTest() { | |||||
| if (!pendingCampaignReview) return; | |||||
| if (!window.confirm("Send this campaign test to " + pendingCampaignReview.TestTo + "?")) return; | |||||
| try { | |||||
| var result = withCampaignRequest("send-campaign-test.ps1", " -Send"); | |||||
| campaignTestAccepted = true; | |||||
| document.getElementById("confirmReceived").disabled = false; | |||||
| document.getElementById("campaignMessage").innerText = result; | |||||
| } catch (error) { | |||||
| document.getElementById("campaignMessage").innerText = "Campaign test failed: " + error.message; | |||||
| } | |||||
| } | |||||
| function updateApprovalButton() { | |||||
| document.getElementById("approveCampaign").disabled = | |||||
| !pendingCampaignReview || !campaignTestAccepted || | |||||
| !document.getElementById("confirmReceived").checked; | |||||
| } | |||||
| function approveReviewedCampaign() { | |||||
| if (!pendingCampaignReview || !campaignTestAccepted || | |||||
| !document.getElementById("confirmReceived").checked) return; | |||||
| if (!window.confirm("Approve " + pendingCampaignReview.Count + | |||||
| " recipients? The campaign will remain paused.")) return; | |||||
| try { | |||||
| var result = withCampaignRequest("approve-campaign.ps1", ""); | |||||
| document.getElementById("campaignMessage").innerText = result; | |||||
| invalidateCampaignReview(); | |||||
| refreshStatus(); | |||||
| } catch (error) { | |||||
| document.getElementById("campaignMessage").innerText = "Approval failed: " + error.message; | |||||
| } | |||||
| } | |||||
| </script> | </script> | ||||
| </head> | </head> | ||||
| <body onload="refreshStatus()"> | <body onload="refreshStatus()"> | ||||
| @@ -400,6 +525,21 @@ | |||||
| <div id="templateMessage"></div> | <div id="templateMessage"></div> | ||||
| <iframe id="templatePreview" APPLICATION="no" title="Template preview"></iframe> | <iframe id="templatePreview" APPLICATION="no" title="Template preview"></iframe> | ||||
| </div> | </div> | ||||
| <div class="panel"> | |||||
| <h2>Review and approve a campaign</h2> | |||||
| <p>Approval captures currently eligible contacts and the selected template version. It requires a complete postal address in config.ini, a campaign-specific internal test, and your confirmation that you received it. Approved campaigns stay paused until dispatch controls are built.</p> | |||||
| <label class="field">Campaign name <input id="campaignName" type="text" maxlength="200" oninput="invalidateCampaignReview()" /></label> | |||||
| <label class="field">Email subject <input id="campaignSubject" type="text" maxlength="240" oninput="invalidateCampaignReview()" /></label> | |||||
| <label class="field">Template version <select id="campaignTemplateVersion" onchange="invalidateCampaignReview()"></select></label> | |||||
| <button onclick="reviewCampaign()">Review message and recipients</button> | |||||
| <div id="campaignReview"></div> | |||||
| <iframe id="campaignPreview" APPLICATION="no" title="Full campaign preview"></iframe> | |||||
| <button id="sendCampaignTest" onclick="sendReviewedCampaignTest()" disabled="disabled">Send reviewed message to internal test inbox</button> | |||||
| <label class="field"><input id="confirmReceived" type="checkbox" disabled="disabled" onchange="updateApprovalButton()" style="display:inline;width:auto" /> I received and checked the campaign test message.</label> | |||||
| <button id="approveCampaign" onclick="approveReviewedCampaign()" disabled="disabled">Approve campaign (paused)</button> | |||||
| <div id="campaignMessage"></div> | |||||
| <table><thead><tr><th>ID</th><th>Campaign</th><th>Status</th></tr></thead><tbody id="campaignBody"></tbody></table> | |||||
| </div> | |||||
| <div class="panel"> | <div class="panel"> | ||||
| <h2>Suppress an address</h2> | <h2>Suppress an address</h2> | ||||
| <p>Add unsubscribe replies here before the next send. Suppression applies across all campaigns.</p> | <p>Add unsubscribe replies here before the next send. Suppression applies across all campaigns.</p> | ||||
| @@ -10,23 +10,25 @@ Windows HTA application under construction for individual marketing emails for a | |||||
| - Imports constrained, well-formed HTML templates from `TEMPLATE_ROOT`, snapshots local PNG/JPEG/GIF images into the MDB, and previews the stored version in an untrusted HTA frame. | - Imports constrained, well-formed HTML templates from `TEMPLATE_ROOT`, snapshots local PNG/JPEG/GIF images into the MDB, and previews the stored version in an untrusted HTA frame. | ||||
| - Stores the SMTP password as a Windows user-protected value in the MDB through an interactive local setup action. | - Stores the SMTP password as a Windows user-protected value in the MDB through an interactive local setup action. | ||||
| - Offers an explicit, single-message SMTP transport test to the configured `SMTP_TEST_TO` address. Campaign sending is not implemented yet. | - Offers an explicit, single-message SMTP transport test to the configured `SMTP_TEST_TO` address. Campaign sending is not implemented yet. | ||||
| - Reviews a campaign against unsuppressed contacts, adds an advertisement, postal, and reply opt-out footer, tests the reviewed message only to the internal inbox, and records approval in a paused state. | |||||
| The app sends no marketing messages in this build. Closing the HTA stops all in-app activity. | The app sends no marketing messages in this build. Closing the HTA stops all in-app activity. | ||||
| ## Setup on this PC | ## 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. | |||||
| 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; campaign review and approval 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. | 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. | 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. | 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. | |||||
| 6. Put template files under `TEMPLATE_ROOT` and import them from the HTA. The accepted HTML subset and example are in [templates/README.md](templates/README.md). Imported versions are immutable snapshots; the preview currently shows the template content only. Campaign footer and approval are still in development. | |||||
| 5. Enter opt-out replies in the suppression form. Press **Set or rotate SMTP password** to enter the password locally, then press **Send one internal test**. The owner reports that this authenticated SMTP test worked on this PC; detailed received-image rendering has not been separately recorded. | |||||
| 6. Put template files under `TEMPLATE_ROOT` and import them from the HTA. The accepted HTML subset and example are in [templates/README.md](templates/README.md). Imported versions are immutable snapshots. The template preview shows stored content, while the campaign preview adds the full footer. | |||||
| 7. Fill in `BUSINESS_POSTAL_ADDRESS` in local `config.ini`. Enter a campaign name and subject, select an imported template, and press **Review message and recipients**. After checking the full preview, send the campaign-specific test to `SMTP_TEST_TO` and confirm it arrived. Approval records paused deliveries; it does not start sending. | |||||
| Local `config.ini`, MDB files, logs, backups, and temporary test data are ignored by Git. | Local `config.ini`, MDB files, logs, backups, and temporary test data are ignored by Git. | ||||
| ## Verification | ## Verification | ||||
| Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-contact-flow.ps1` for a disposable MDB integration check. It covers database initialization and preflight, protected password save/load/rotation using synthetic values, local MIME pickup with an inline image, contact import, duplicate rejection, and suppression. Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-template-flow.ps1` for template import, snapshot, preview, and unsafe-input checks. `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. | |||||
| Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-contact-flow.ps1` for a disposable MDB integration check. It covers database initialization and preflight, protected password save/load/rotation using synthetic values, local MIME pickup with an inline image, contact import, duplicate rejection, and suppression. Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-template-flow.ps1` for template import, snapshot, preview, and unsafe-input checks. Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-campaign-flow.ps1` for recipient review, stale-review rejection, internal-only campaign MIME, and paused approval. `C:\Windows\SysWOW64\cscript.exe //nologo //E:JScript tools\test-contacts.js` covers CSV parsing and validation. `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-hta-syntax.ps1` checks the HTA script. `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-smtp-capabilities.ps1` checks the configured server's STARTTLS handshake without authenticating or sending mail. | |||||
| ## Planning documents | ## Planning documents | ||||
| @@ -37,4 +39,4 @@ Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-contact- | |||||
| - [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) | ||||
| 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. | |||||
| System-wide settings belong in the INI; campaign settings, templates, contacts, queue state, and protected secrets belong in the Access MDB. Live campaign dispatch, pacing, recovery, and reporting are still under development. | |||||
| @@ -14,4 +14,4 @@ Example: | |||||
| </body></html> | </body></html> | ||||
| ``` | ``` | ||||
| Create the `images/postcard.png` file beside the source HTML before importing. The template preview shows imported content and images. Campaign-specific sender, postal address, ad identification, and opt-out footer are part of the later approval workflow and are not yet shown in this preview. Campaign sending remains disabled. | |||||
| Create the `images/postcard.png` file beside the source HTML before importing. The template preview shows imported content and images. The campaign review preview adds the sender, postal address, ad identification, and opt-out footer. Campaign sending remains disabled. | |||||
| @@ -0,0 +1,62 @@ | |||||
| param( | |||||
| [Parameter(Mandatory = $true)][string]$RequestPath, | |||||
| [string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini') | |||||
| ) | |||||
| $ErrorActionPreference = 'Stop' | |||||
| . (Join-Path $PSScriptRoot 'campaign-common.ps1') | |||||
| $context = Get-MailContext -ConfigPath $ConfigPath | |||||
| $request = Read-CampaignRequest $RequestPath | |||||
| if (-not $request.TestReceived) { throw 'Confirm that the campaign test was received before approval' } | |||||
| $db = Open-MailDatabase $context | |||||
| $transaction = $null | |||||
| try { | |||||
| $transaction = $db.BeginTransaction() | |||||
| $review = Get-CampaignReview $context $request $db $transaction | |||||
| Assert-ReviewToken $request $review | |||||
| $check = New-DbCommand $db "SELECT COUNT(*) FROM [AuditEvent] WHERE [Action]='campaign_test_accepted' AND [EntityId]=?" $transaction | |||||
| Add-TextParameter $check 'token' 100 $review.Token | |||||
| if ([int]$check.ExecuteScalar() -lt 1) { throw 'Send the reviewed campaign to the internal test inbox first' } | |||||
| $check = New-DbCommand $db "SELECT COUNT(*) FROM [AuditEvent] WHERE [Action]='campaign_approved' AND [EntityId]=?" $transaction | |||||
| Add-TextParameter $check 'token' 100 $review.Token | |||||
| if ([int]$check.ExecuteScalar() -gt 0) { throw 'This exact campaign review was already approved' } | |||||
| $command = New-DbCommand $db 'INSERT INTO [Campaign] ([Name],[SenderKey],[FromSnapshot],[ReplyToSnapshot],[PostalAddressSnapshot],[Subject],[TemplateVersionId],[Status],[StartAtUtc],[ApprovedAtUtc]) VALUES (?,?,?,?,?,?,?,?,?,?)' $transaction | |||||
| Add-TextParameter $command 'name' 200 $review.Name | |||||
| Add-TextParameter $command 'sender' 254 $context.Settings['SMTP_USER'].ToLowerInvariant() | |||||
| Add-TextParameter $command 'from' 254 $review.Identity.From | |||||
| Add-TextParameter $command 'reply' 254 $review.Identity.ReplyTo | |||||
| $null = $command.Parameters.Add('postal', [System.Data.OleDb.OleDbType]::LongVarWChar) | |||||
| $command.Parameters[4].Value = $review.Identity.Postal | |||||
| Add-TextParameter $command 'subject' 255 $review.Subject | |||||
| $null = $command.Parameters.Add('template', [System.Data.OleDb.OleDbType]::Integer) | |||||
| $command.Parameters[6].Value = $review.TemplateId | |||||
| Add-TextParameter $command 'status' 30 'approved_paused' | |||||
| $null = $command.Parameters.Add('start', [System.Data.OleDb.OleDbType]::Date) | |||||
| $command.Parameters[8].Value = [DateTime]::UtcNow | |||||
| $null = $command.Parameters.Add('approved', [System.Data.OleDb.OleDbType]::Date) | |||||
| $command.Parameters[9].Value = [DateTime]::UtcNow | |||||
| $null = $command.ExecuteNonQuery() | |||||
| $identity = New-DbCommand $db 'SELECT @@IDENTITY' $transaction | |||||
| $campaignId = [int]$identity.ExecuteScalar() | |||||
| foreach ($contact in $review.Contacts) { | |||||
| $delivery = New-DbCommand $db 'INSERT INTO [Delivery] ([CampaignId],[ContactId],[EmailKey],[Status],[AttemptCount]) VALUES (?,?,?,?,?)' $transaction | |||||
| $null = $delivery.Parameters.Add('campaign', [System.Data.OleDb.OleDbType]::Integer) | |||||
| $delivery.Parameters[0].Value = $campaignId | |||||
| $null = $delivery.Parameters.Add('contact', [System.Data.OleDb.OleDbType]::Integer) | |||||
| $delivery.Parameters[1].Value = $contact.Id | |||||
| Add-TextParameter $delivery 'email' 254 $contact.Key | |||||
| Add-TextParameter $delivery 'status' 30 'pending' | |||||
| $null = $delivery.Parameters.Add('attempts', [System.Data.OleDb.OleDbType]::Integer) | |||||
| $delivery.Parameters[4].Value = 0 | |||||
| $null = $delivery.ExecuteNonQuery() | |||||
| } | |||||
| Write-CampaignAudit $db 'campaign_approved' $review.Token ("Campaign $campaignId, $($review.Contacts.Count) recipients") $transaction | |||||
| $transaction.Commit() | |||||
| $transaction = $null | |||||
| Write-Output "Approved campaign $campaignId with $($review.Contacts.Count) queued recipients. It remains paused." | |||||
| } | |||||
| catch { | |||||
| if ($transaction) { $transaction.Rollback() } | |||||
| throw | |||||
| } | |||||
| finally { $db.Dispose() } | |||||
| @@ -0,0 +1,199 @@ | |||||
| $ErrorActionPreference = 'Stop' | |||||
| . (Join-Path $PSScriptRoot 'template-common.ps1') | |||||
| function Read-CampaignRequest { | |||||
| param([string]$Path) | |||||
| if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw 'Campaign request file not found' } | |||||
| if ((Get-Item -LiteralPath $Path).Length -gt 8192) { throw 'Campaign request is too large' } | |||||
| $request = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json | |||||
| $name = ([string]$request.Name).Trim() | |||||
| $subject = ([string]$request.Subject).Trim() | |||||
| $id = 0 | |||||
| if (-not [int]::TryParse([string]$request.TemplateId, [ref]$id) -or $id -lt 1) { | |||||
| throw 'Select a template version' | |||||
| } | |||||
| if ($name.Length -lt 1 -or $name.Length -gt 200 -or $name -match '[\x00-\x1f]') { | |||||
| throw 'Campaign name must be 1-200 characters without control characters' | |||||
| } | |||||
| if ($subject.Length -lt 1 -or $subject.Length -gt 240 -or $subject -match '[\x00-\x1f]') { | |||||
| throw 'Subject must be 1-240 characters without control characters' | |||||
| } | |||||
| return @{ Name = $name; Subject = $subject; TemplateId = $id; | |||||
| ReviewToken = ([string]$request.ReviewToken).Trim(); TestReceived = ($request.TestReceived -eq $true) } | |||||
| } | |||||
| function Get-CampaignIdentity { | |||||
| param($Context) | |||||
| $settings = $Context.Settings | |||||
| foreach ($key in @('SMTP_FROM','SMTP_REPLY_TO','SMTP_TEST_TO','BUSINESS_POSTAL_ADDRESS')) { | |||||
| if (-not $settings[$key] -or -not $settings[$key].Trim()) { throw "Set $key in config.ini first" } | |||||
| } | |||||
| $from = $settings['SMTP_FROM'].Trim() | |||||
| $replyTo = $settings['SMTP_REPLY_TO'].Trim() | |||||
| $testTo = $settings['SMTP_TEST_TO'].Trim() | |||||
| foreach ($address in @($from, $replyTo, $testTo)) { | |||||
| $parsed = New-Object System.Net.Mail.MailAddress($address) | |||||
| if ($parsed.Address -ne $address) { throw 'SMTP addresses must each be a single bare email address' } | |||||
| } | |||||
| $postal = $settings['BUSINESS_POSTAL_ADDRESS'].Trim() | |||||
| if ($postal.Length -gt 500 -or $postal -match '[\x00-\x1f]') { | |||||
| throw 'BUSINESS_POSTAL_ADDRESS must be one line of at most 500 characters' | |||||
| } | |||||
| return @{ From = $from; ReplyTo = $replyTo; TestTo = $testTo; Postal = $postal } | |||||
| } | |||||
| function New-DbCommand { | |||||
| param($Connection, [string]$Sql, $Transaction = $null) | |||||
| $command = $Connection.CreateCommand() | |||||
| $command.CommandText = $Sql | |||||
| if ($Transaction) { $command.Transaction = $Transaction } | |||||
| return $command | |||||
| } | |||||
| function Get-EligibleContacts { | |||||
| param($Connection, $Transaction = $null) | |||||
| $command = New-DbCommand $Connection "SELECT c.[Id],c.[EmailOriginal],c.[EmailKey] FROM [Contact] AS c LEFT JOIN [Suppression] AS s ON c.[EmailKey]=s.[EmailKey] WHERE s.[EmailKey] IS NULL AND c.[Country]='US' AND c.[EligibilityBasis]='public_listing' ORDER BY c.[Id]" $Transaction | |||||
| $reader = $command.ExecuteReader() | |||||
| $contacts = New-Object System.Collections.ArrayList | |||||
| try { | |||||
| while ($reader.Read()) { | |||||
| $null = $contacts.Add(@{ Id = [int]$reader.GetValue(0); | |||||
| Email = [string]$reader.GetValue(1); Key = [string]$reader.GetValue(2) }) | |||||
| } | |||||
| } | |||||
| finally { $reader.Dispose() } | |||||
| if ($contacts.Count -lt 1) { throw 'No eligible, unsuppressed US contacts are available' } | |||||
| if ($contacts.Count -gt 500) { throw 'This version supports at most 500 eligible contacts per campaign' } | |||||
| return ,$contacts | |||||
| } | |||||
| function Get-TemplateSnapshot { | |||||
| param($Connection, [int]$TemplateId, $Transaction = $null) | |||||
| $command = New-DbCommand $Connection 'SELECT [TemplateName],[VersionNumber],[HtmlContent],[ContentHash],[ValidationStatus] FROM [TemplateVersion] WHERE [Id]=?' $Transaction | |||||
| $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer) | |||||
| $command.Parameters[0].Value = $TemplateId | |||||
| $reader = $command.ExecuteReader() | |||||
| try { | |||||
| if (-not $reader.Read()) { throw 'Selected template version was not found' } | |||||
| $name = [string]$reader.GetValue(0) | |||||
| $version = [int]$reader.GetValue(1) | |||||
| $html = [string]$reader.GetValue(2) | |||||
| $hash = [string]$reader.GetValue(3) | |||||
| if ([string]$reader.GetValue(4) -ne 'validated' -or | |||||
| (Get-HashHex ([System.Text.Encoding]::UTF8.GetBytes($html))) -ne $hash) { | |||||
| throw 'Template validation or content hash failed' | |||||
| } | |||||
| } | |||||
| finally { $reader.Dispose() } | |||||
| $document = Read-SafeTemplateXml $html $true | |||||
| $images = New-Object System.Collections.ArrayList | |||||
| $command = New-DbCommand $Connection 'SELECT [ContentType],[ContentHash],[SnapshotBytes],[ContentId] FROM [TemplateImage] WHERE [TemplateVersionId]=? ORDER BY [Id]' $Transaction | |||||
| $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer) | |||||
| $command.Parameters[0].Value = $TemplateId | |||||
| $reader = $command.ExecuteReader() | |||||
| try { | |||||
| while ($reader.Read()) { | |||||
| $bytes = [byte[]]$reader.GetValue(2) | |||||
| $cid = [string]$reader.GetValue(3) | |||||
| $type = [string]$reader.GetValue(0) | |||||
| if ((Get-HashHex $bytes) -ne [string]$reader.GetValue(1) -or | |||||
| $cid -notmatch '^mm-[0-9a-f]{32}@local$' -or | |||||
| $type -notin @('image/png','image/jpeg','image/gif')) { | |||||
| throw 'Stored template image failed validation' | |||||
| } | |||||
| $null = $images.Add(@{ Bytes = $bytes; Cid = $cid; Type = $type }) | |||||
| } | |||||
| } | |||||
| finally { $reader.Dispose() } | |||||
| $references = @{} | |||||
| foreach ($image in $images) { $references['cid:' + $image.Cid] = $false } | |||||
| foreach ($node in $document.GetElementsByTagName('img')) { | |||||
| $src = $node.GetAttribute('src') | |||||
| if (-not $references.ContainsKey($src)) { throw 'Template image reference has no stored snapshot' } | |||||
| $references[$src] = $true | |||||
| } | |||||
| foreach ($entry in $references.GetEnumerator()) { | |||||
| if (-not $entry.Value) { throw 'Stored template image has no HTML reference' } | |||||
| } | |||||
| return @{ Name = $name; Version = $version; Html = $html; Hash = $hash; | |||||
| Document = $document; Images = $images } | |||||
| } | |||||
| function Add-CampaignFooter { | |||||
| param($Document, $Identity) | |||||
| $body = @($Document.DocumentElement.ChildNodes | Where-Object { $_.LocalName -eq 'body' })[0] | |||||
| if (-not $body) { throw 'Stored template is missing <body>' } | |||||
| $ns = $Document.DocumentElement.NamespaceURI | |||||
| $divider = $Document.CreateElement('hr', $ns) | |||||
| $null = $body.AppendChild($divider) | |||||
| $footer = $Document.CreateElement('div', $ns) | |||||
| $footer.SetAttribute('style', 'font-size:12px; color:#444444') | |||||
| foreach ($line in @( | |||||
| 'Advertisement from ' + $Identity.From + '.', | |||||
| $Identity.Postal, | |||||
| 'To stop receiving marketing emails, reply to ' + $Identity.ReplyTo + ' with UNSUBSCRIBE in the subject.' | |||||
| )) { | |||||
| $paragraph = $Document.CreateElement('p', $ns) | |||||
| $null = $paragraph.AppendChild($Document.CreateTextNode($line)) | |||||
| $null = $footer.AppendChild($paragraph) | |||||
| } | |||||
| $null = $body.AppendChild($footer) | |||||
| return $Document.OuterXml | |||||
| } | |||||
| function Get-CampaignReview { | |||||
| param($Context, $Request, $Connection, $Transaction = $null) | |||||
| $identity = Get-CampaignIdentity $Context | |||||
| $template = Get-TemplateSnapshot $Connection $Request.TemplateId $Transaction | |||||
| $contacts = Get-EligibleContacts $Connection $Transaction | |||||
| $html = Add-CampaignFooter $template.Document $identity | |||||
| $parts = New-Object System.Collections.ArrayList | |||||
| foreach ($value in @($Request.Name, $Request.Subject, [string]$Request.TemplateId, | |||||
| $template.Hash, $identity.From, $identity.ReplyTo, $identity.Postal)) { | |||||
| $null = $parts.Add($value) | |||||
| } | |||||
| foreach ($contact in $contacts) { $null = $parts.Add(([string]$contact.Id + ':' + $contact.Key)) } | |||||
| $token = Get-HashHex ([System.Text.Encoding]::UTF8.GetBytes(($parts -join [char]0))) | |||||
| return @{ Name = $Request.Name; Subject = $Request.Subject; TemplateId = $Request.TemplateId; | |||||
| TemplateName = $template.Name; TemplateVersion = $template.Version; Identity = $identity; | |||||
| Contacts = $contacts; Html = $html; Images = $template.Images; Token = $token } | |||||
| } | |||||
| function Assert-ReviewToken { | |||||
| param($Request, $Review) | |||||
| if ($Request.ReviewToken -notmatch '^[0-9a-f]{64}$' -or $Request.ReviewToken -ne $Review.Token) { | |||||
| throw 'Campaign inputs, recipients, template, or settings changed. Review again.' | |||||
| } | |||||
| } | |||||
| function Write-CampaignPreview { | |||||
| param($Review, [string]$OutputRoot) | |||||
| $folder = Join-Path $OutputRoot ([guid]::NewGuid().ToString('N')) | |||||
| $null = New-Item -ItemType Directory -Path $folder -Force | |||||
| $document = Read-SafeTemplateXml $Review.Html $true | |||||
| $index = 0 | |||||
| foreach ($image in $Review.Images) { | |||||
| $index++ | |||||
| $extension = switch ($image.Type) { 'image/png' { 'png' } 'image/jpeg' { 'jpg' } 'image/gif' { 'gif' } } | |||||
| $filename = "image-$index.$extension" | |||||
| [System.IO.File]::WriteAllBytes((Join-Path $folder $filename), $image.Bytes) | |||||
| foreach ($node in $document.GetElementsByTagName('img')) { | |||||
| if ($node.GetAttribute('src') -eq ('cid:' + $image.Cid)) { $node.SetAttribute('src', $filename) } | |||||
| } | |||||
| } | |||||
| $file = Join-Path $folder 'preview.html' | |||||
| [System.IO.File]::WriteAllText($file, $document.OuterXml, (New-Object System.Text.UTF8Encoding($false))) | |||||
| return $file | |||||
| } | |||||
| function Write-CampaignAudit { | |||||
| param($Connection, [string]$Action, [string]$Token, [string]$Detail, $Transaction = $null) | |||||
| $command = New-DbCommand $Connection 'INSERT INTO [AuditEvent] ([Actor],[Action],[EntityId],[TimestampUtc],[Detail]) VALUES (?,?,?,?,?)' $Transaction | |||||
| Add-TextParameter $command 'actor' 100 'local_operator' | |||||
| Add-TextParameter $command 'action' 100 $Action | |||||
| Add-TextParameter $command 'entity' 100 $Token | |||||
| $null = $command.Parameters.Add('at', [System.Data.OleDb.OleDbType]::Date) | |||||
| $command.Parameters[3].Value = [DateTime]::UtcNow | |||||
| Add-TextParameter $command 'detail' 255 $Detail | |||||
| $null = $command.ExecuteNonQuery() | |||||
| } | |||||
| @@ -0,0 +1,27 @@ | |||||
| param( | |||||
| [Parameter(Mandatory = $true)][string]$RequestPath, | |||||
| [string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'), | |||||
| [string]$OutputRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'temp\previews') | |||||
| ) | |||||
| $ErrorActionPreference = 'Stop' | |||||
| . (Join-Path $PSScriptRoot 'campaign-common.ps1') | |||||
| $context = Get-MailContext -ConfigPath $ConfigPath | |||||
| $request = Read-CampaignRequest $RequestPath | |||||
| $db = Open-MailDatabase $context | |||||
| try { | |||||
| $review = Get-CampaignReview $context $request $db | |||||
| $previewPath = Write-CampaignPreview $review $OutputRoot | |||||
| [pscustomobject]@{ | |||||
| Count = $review.Contacts.Count | |||||
| Token = $review.Token | |||||
| PreviewPath = $previewPath | |||||
| TemplateName = $review.TemplateName | |||||
| TemplateVersion = $review.TemplateVersion | |||||
| From = $review.Identity.From | |||||
| ReplyTo = $review.Identity.ReplyTo | |||||
| TestTo = $review.Identity.TestTo | |||||
| PostalAddress = $review.Identity.Postal | |||||
| Subject = $review.Subject | |||||
| } | ConvertTo-Json -Compress | |||||
| } | |||||
| finally { $db.Dispose() } | |||||
| @@ -0,0 +1,67 @@ | |||||
| param( | |||||
| [Parameter(Mandatory = $true)][string]$RequestPath, | |||||
| [string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'), | |||||
| [switch]$Send, | |||||
| [string]$PickupDirectory | |||||
| ) | |||||
| $ErrorActionPreference = 'Stop' | |||||
| . (Join-Path $PSScriptRoot 'campaign-common.ps1') | |||||
| if ([bool]$Send -eq [bool]$PickupDirectory) { throw 'Choose exactly one mode: -Send or -PickupDirectory' } | |||||
| $context = Get-MailContext -ConfigPath $ConfigPath | |||||
| $request = Read-CampaignRequest $RequestPath | |||||
| $settings = $context.Settings | |||||
| 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' | |||||
| } | |||||
| $db = Open-MailDatabase $context | |||||
| $client = $null | |||||
| $message = $null | |||||
| $password = $null | |||||
| try { | |||||
| $review = Get-CampaignReview $context $request $db | |||||
| Assert-ReviewToken $request $review | |||||
| $message = New-Object System.Net.Mail.MailMessage | |||||
| $message.From = New-Object System.Net.Mail.MailAddress($review.Identity.From) | |||||
| $null = $message.ReplyToList.Add($review.Identity.ReplyTo) | |||||
| $null = $message.To.Add($review.Identity.TestTo) | |||||
| $message.Subject = '[TEST] ' + $review.Subject | |||||
| $message.SubjectEncoding = [System.Text.Encoding]::UTF8 | |||||
| $view = [System.Net.Mail.AlternateView]::CreateAlternateViewFromString( | |||||
| $review.Html, [System.Text.Encoding]::UTF8, 'text/html') | |||||
| foreach ($image in $review.Images) { | |||||
| $stream = New-Object System.IO.MemoryStream -ArgumentList (,([byte[]]$image.Bytes)) | |||||
| $resource = New-Object System.Net.Mail.LinkedResource($stream, $image.Type) | |||||
| $resource.ContentId = $image.Cid | |||||
| $resource.TransferEncoding = [System.Net.Mime.TransferEncoding]::Base64 | |||||
| $null = $view.LinkedResources.Add($resource) | |||||
| } | |||||
| $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 | |||||
| $client.UseDefaultCredentials = $false | |||||
| $client.Credentials = New-Object System.Net.NetworkCredential -ArgumentList @($settings['SMTP_USER'], $password) | |||||
| $client.EnableSsl = $true | |||||
| } | |||||
| else { | |||||
| if (-not (Test-Path -LiteralPath $PickupDirectory -PathType Container)) { | |||||
| throw 'Pickup directory does not exist' | |||||
| } | |||||
| $client.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::SpecifiedPickupDirectory | |||||
| $client.PickupDirectoryLocation = [System.IO.Path]::GetFullPath($PickupDirectory) | |||||
| } | |||||
| $client.Send($message) | |||||
| Write-CampaignAudit $db 'campaign_test_accepted' $review.Token ("Internal test to $($review.Identity.TestTo)") | |||||
| Write-Output "Campaign test accepted for $($review.Identity.TestTo). Check the received message before approval." | |||||
| } | |||||
| finally { | |||||
| if ($client) { $client.Dispose() } | |||||
| if ($message) { $message.Dispose() } | |||||
| if ($password) { $password.Dispose() } | |||||
| $db.Dispose() | |||||
| } | |||||
| @@ -94,17 +94,29 @@ function Read-SafeTemplate { | |||||
| if ([System.IO.Path]::GetExtension($Path).ToLowerInvariant() -notin @('.html', '.htm')) { | if ([System.IO.Path]::GetExtension($Path).ToLowerInvariant() -notin @('.html', '.htm')) { | ||||
| throw 'Select a .html or .htm template' | throw 'Select a .html or .htm template' | ||||
| } | } | ||||
| return Read-SafeTemplateXml $Path $false | |||||
| } | |||||
| function Read-SafeTemplateXml { | |||||
| param([string]$Source, [bool]$IsText) | |||||
| $readerSettings = New-Object System.Xml.XmlReaderSettings | $readerSettings = New-Object System.Xml.XmlReaderSettings | ||||
| $readerSettings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit | $readerSettings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit | ||||
| $readerSettings.XmlResolver = $null | $readerSettings.XmlResolver = $null | ||||
| $readerSettings.MaxCharactersInDocument = 2097152 | $readerSettings.MaxCharactersInDocument = 2097152 | ||||
| $reader = [System.Xml.XmlReader]::Create($Path, $readerSettings) | |||||
| if ($IsText) { | |||||
| $sourceReader = New-Object System.IO.StringReader($Source) | |||||
| $reader = [System.Xml.XmlReader]::Create($sourceReader, $readerSettings) | |||||
| } | |||||
| else { $reader = [System.Xml.XmlReader]::Create($Source, $readerSettings) } | |||||
| try { | try { | ||||
| $document = New-Object System.Xml.XmlDocument | $document = New-Object System.Xml.XmlDocument | ||||
| $document.XmlResolver = $null | $document.XmlResolver = $null | ||||
| $document.Load($reader) | $document.Load($reader) | ||||
| } | } | ||||
| finally { $reader.Dispose() } | |||||
| finally { | |||||
| $reader.Dispose() | |||||
| if ($IsText) { $sourceReader.Dispose() } | |||||
| } | |||||
| if ($document.DocumentElement.LocalName.ToLowerInvariant() -ne 'html') { | if ($document.DocumentElement.LocalName.ToLowerInvariant() -ne 'html') { | ||||
| throw 'Template must have an <html> root' | throw 'Template must have an <html> root' | ||||
| } | } | ||||
| @@ -0,0 +1,45 @@ | |||||
| param( | |||||
| [Parameter(Mandatory = $true)][string]$ConfigPath, | |||||
| [Parameter(Mandatory = $true)][ValidateSet('Seed','SuppressSecond','Check')][string]$Mode | |||||
| ) | |||||
| $ErrorActionPreference = 'Stop' | |||||
| . (Join-Path $PSScriptRoot 'mail-common.ps1') | |||||
| $context = Get-MailContext $ConfigPath | |||||
| $db = Open-MailDatabase $context | |||||
| try { | |||||
| if ($Mode -eq 'Seed') { | |||||
| foreach ($address in @('one@example.invalid','two@example.invalid','blocked@example.invalid')) { | |||||
| $command = $db.CreateCommand() | |||||
| $command.CommandText = "INSERT INTO [Contact] ([EmailOriginal],[EmailKey],[BusinessName],[SourceReference],[Country],[EligibilityBasis],[CreatedAtUtc]) VALUES (?,?,?,?,?,?,?)" | |||||
| foreach ($value in @($address,$address,'Example Business','https://example.invalid/listing','US','public_listing')) { | |||||
| $null = $command.Parameters.Add('text', [System.Data.OleDb.OleDbType]::VarWChar, 254) | |||||
| $command.Parameters[$command.Parameters.Count - 1].Value = $value | |||||
| } | |||||
| $null = $command.Parameters.Add('created', [System.Data.OleDb.OleDbType]::Date) | |||||
| $command.Parameters[6].Value = [DateTime]::UtcNow | |||||
| $null = $command.ExecuteNonQuery() | |||||
| } | |||||
| } | |||||
| if ($Mode -in @('Seed','SuppressSecond')) { | |||||
| $address = if ($Mode -eq 'Seed') { 'blocked@example.invalid' } else { 'two@example.invalid' } | |||||
| $command = $db.CreateCommand() | |||||
| $command.CommandText = 'INSERT INTO [Suppression] ([EmailKey],[Reason],[RecordedAtUtc]) VALUES (?,?,?)' | |||||
| $null = $command.Parameters.Add('email', [System.Data.OleDb.OleDbType]::VarWChar, 254) | |||||
| $command.Parameters[0].Value = $address | |||||
| $null = $command.Parameters.Add('reason', [System.Data.OleDb.OleDbType]::VarWChar, 40) | |||||
| $command.Parameters[1].Value = 'opt-out' | |||||
| $null = $command.Parameters.Add('recorded', [System.Data.OleDb.OleDbType]::Date) | |||||
| $command.Parameters[2].Value = [DateTime]::UtcNow | |||||
| $null = $command.ExecuteNonQuery() | |||||
| } | |||||
| if ($Mode -eq 'Check') { | |||||
| $command = $db.CreateCommand() | |||||
| $command.CommandText = "SELECT COUNT(*) FROM [Campaign] WHERE [Status]='approved_paused'" | |||||
| if ([int]$command.ExecuteScalar() -ne 1) { throw 'Expected one approved paused campaign' } | |||||
| $command.CommandText = "SELECT COUNT(*) FROM [Delivery] WHERE [Status]='pending'" | |||||
| if ([int]$command.ExecuteScalar() -ne 1) { throw 'Expected one pending delivery' } | |||||
| $command.CommandText = 'SELECT COUNT(*) FROM [Attempt]' | |||||
| if ([int]$command.ExecuteScalar() -ne 0) { throw 'Internal test unexpectedly advanced the live attempt queue' } | |||||
| } | |||||
| } | |||||
| finally { $db.Dispose() } | |||||
| @@ -0,0 +1,94 @@ | |||||
| $ErrorActionPreference = 'Stop' | |||||
| $root = Split-Path -Parent $PSScriptRoot | |||||
| $work = Join-Path $root ('temp\campaign-flow-' + [guid]::NewGuid().ToString('N')) | |||||
| $templateRoot = Join-Path $work 'templates' | |||||
| $assets = Join-Path $templateRoot 'images' | |||||
| $dbPath = Join-Path $work 'test.mdb' | |||||
| $configPath = Join-Path $work 'test.ini' | |||||
| $requestPath = Join-Path $work 'request.json' | |||||
| $templatePath = Join-Path $templateRoot 'offer.html' | |||||
| $imagePath = Join-Path $assets 'logo.png' | |||||
| $pickup = Join-Path $work 'pickup' | |||||
| $scriptHost = Join-Path $env:SystemRoot 'SysWOW64\cscript.exe' | |||||
| $powerShellX86 = Join-Path $env:SystemRoot 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe' | |||||
| function Save-Request($Object) { | |||||
| $Object | ConvertTo-Json -Compress | Set-Content -LiteralPath $requestPath -Encoding UTF8 | |||||
| } | |||||
| function Expect-Failure([string]$ScriptPath, [string[]]$Arguments) { | |||||
| $ErrorActionPreference = 'Continue' | |||||
| $null = & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File $ScriptPath @Arguments 2>$null | |||||
| $failed = $LASTEXITCODE -ne 0 | |||||
| $ErrorActionPreference = 'Stop' | |||||
| if (-not $failed) { throw "Expected failure from $ScriptPath" } | |||||
| } | |||||
| try { | |||||
| $null = New-Item -ItemType Directory -Path $assets -Force | |||||
| $null = New-Item -ItemType Directory -Path $pickup -Force | |||||
| $example = Get-Content -LiteralPath (Join-Path $root 'config.example.ini') -Raw | |||||
| $example.Replace('DB_PATH=data/MassMailer.mdb', ('DB_PATH=' + $dbPath)).Replace( | |||||
| 'TEMPLATE_ROOT=templates', ('TEMPLATE_ROOT=' + $templateRoot)).Replace( | |||||
| 'SMTP_TEST_TO=', 'SMTP_TEST_TO=owner@example.invalid').Replace( | |||||
| 'BUSINESS_POSTAL_ADDRESS=', 'BUSINESS_POSTAL_ADDRESS=123 Example Street, Test City, NY 10001') | | |||||
| Set-Content -LiteralPath $configPath -Encoding UTF8 | |||||
| & $scriptHost //nologo (Join-Path $PSScriptRoot 'init-db.vbs') $configPath | |||||
| if ($LASTEXITCODE -ne 0) { throw 'MDB initialization failed' } | |||||
| & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'test-campaign-db.ps1') -ConfigPath $configPath -Mode Seed | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Contact fixture failed' } | |||||
| [System.IO.File]::WriteAllBytes($imagePath, [Convert]::FromBase64String( | |||||
| 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=')) | |||||
| [System.IO.File]::WriteAllText($templatePath, | |||||
| '<html><body><h1>Postcard offer</h1><img src="images/logo.png" alt="Logo" /></body></html>', | |||||
| [System.Text.Encoding]::UTF8) | |||||
| & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'import-template.ps1') -Path $templatePath -ConfigPath $configPath | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Template fixture failed' } | |||||
| $request = @{ Name = 'Autumn postcard'; Subject = 'Advertising on our postcard'; | |||||
| TemplateId = 1; ReviewToken = ''; TestReceived = $false } | |||||
| Save-Request $request | |||||
| $review = (& $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'review-campaign.ps1') -RequestPath $requestPath -ConfigPath $configPath -OutputRoot (Join-Path $work 'previews')) | ConvertFrom-Json | |||||
| if ($LASTEXITCODE -ne 0 -or $review.Count -ne 2 -or -not (Test-Path -LiteralPath $review.PreviewPath)) { | |||||
| throw 'Campaign review count or preview failed' | |||||
| } | |||||
| $previewHtml = Get-Content -LiteralPath $review.PreviewPath -Raw | |||||
| if ($previewHtml -notmatch '123 Example Street' -or $previewHtml -notmatch 'UNSUBSCRIBE' -or | |||||
| $previewHtml -notmatch 'image-1.png') { throw 'Full preview is missing footer or image' } | |||||
| $request.ReviewToken = $review.Token | |||||
| Save-Request $request | |||||
| Expect-Failure (Join-Path $PSScriptRoot 'approve-campaign.ps1') @('-RequestPath',$requestPath,'-ConfigPath',$configPath) | |||||
| & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'test-campaign-db.ps1') -ConfigPath $configPath -Mode SuppressSecond | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Suppression fixture failed' } | |||||
| Expect-Failure (Join-Path $PSScriptRoot 'send-campaign-test.ps1') @('-RequestPath',$requestPath,'-ConfigPath',$configPath,'-PickupDirectory',$pickup) | |||||
| $review = (& $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'review-campaign.ps1') -RequestPath $requestPath -ConfigPath $configPath -OutputRoot (Join-Path $work 'previews')) | ConvertFrom-Json | |||||
| if ($LASTEXITCODE -ne 0 -or $review.Count -ne 1 -or $review.Token -eq $request.ReviewToken) { | |||||
| throw 'Suppression did not invalidate the review token' | |||||
| } | |||||
| $request.ReviewToken = $review.Token | |||||
| Save-Request $request | |||||
| & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'send-campaign-test.ps1') -RequestPath $requestPath -ConfigPath $configPath -PickupDirectory $pickup | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Internal campaign pickup test failed' } | |||||
| $messages = @(Get-ChildItem -LiteralPath $pickup -Filter '*.eml' -File) | |||||
| if ($messages.Count -ne 1) { throw 'Expected exactly one internal test MIME message' } | |||||
| $mime = Get-Content -LiteralPath $messages[0].FullName -Raw | |||||
| if ($mime -notmatch 'multipart/related' -or $mime -notmatch 'Content-ID:' -or | |||||
| $mime -notmatch 'owner@example.invalid' -or $mime -match 'one@example.invalid|two@example.invalid') { | |||||
| throw 'Internal test MIME has wrong recipient or inline image' | |||||
| } | |||||
| $request.TestReceived = $true | |||||
| Save-Request $request | |||||
| & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'approve-campaign.ps1') -RequestPath $requestPath -ConfigPath $configPath | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Campaign approval failed' } | |||||
| & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'test-campaign-db.ps1') -ConfigPath $configPath -Mode Check | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Campaign delivery state failed' } | |||||
| Expect-Failure (Join-Path $PSScriptRoot 'approve-campaign.ps1') @('-RequestPath',$requestPath,'-ConfigPath',$configPath) | |||||
| Write-Output 'Campaign review, stale-review rejection, internal-only MIME, and paused approval passed' | |||||
| } | |||||
| finally { | |||||
| $resolvedWork = [System.IO.Path]::GetFullPath($work) | |||||
| $resolvedTemp = [System.IO.Path]::GetFullPath((Join-Path $root 'temp')) | |||||
| if ($resolvedWork.StartsWith($resolvedTemp + [System.IO.Path]::DirectorySeparatorChar, | |||||
| [StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $resolvedWork)) { | |||||
| Remove-Item -LiteralPath $resolvedWork -Recurse -Force | |||||
| } | |||||
| } | |||||
Powered by TurnKey Linux.