| @@ -10,7 +10,7 @@ | |||||
| | 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; 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. | | | 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. | | |||||
| | 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 | 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. | | | 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. | | ||||
| @@ -2,7 +2,7 @@ | |||||
| ## Current milestone | ## 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. | |||||
| 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. | |||||
| ## Decisions | ## Decisions | ||||
| @@ -16,4 +16,4 @@ F01-F04 and F06 are implemented and locally verified. F05 has a successful unaut | |||||
| - 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 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. | - 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). | |||||
| - Implement campaign approval and in-app dispatch (F08-F09), including a full message preview with the required footer. | |||||
| @@ -27,6 +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; } | |||||
| </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"> | ||||
| @@ -91,6 +92,7 @@ | |||||
| document.getElementById("interval").innerText = opened.config.QUEUE_INTERVAL + " seconds"; | document.getElementById("interval").innerText = opened.config.QUEUE_INTERVAL + " seconds"; | ||||
| 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(); | |||||
| } catch (error) { | } catch (error) { | ||||
| document.getElementById("error").innerText = "Status refresh failed: " + error.message; | document.getElementById("error").innerText = "Status refresh failed: " + error.message; | ||||
| } finally { | } finally { | ||||
| @@ -290,6 +292,63 @@ | |||||
| document.getElementById("transportMessage").innerText = "Test failed: " + error.message; | document.getElementById("transportMessage").innerText = "Test failed: " + error.message; | ||||
| } | } | ||||
| } | } | ||||
| function refreshTemplates() { | |||||
| var db = null; | |||||
| try { | |||||
| db = openDatabase().db; | |||||
| var rows = db.Execute("SELECT [Id],[TemplateName],[VersionNumber] FROM [TemplateVersion] WHERE [ValidationStatus]='validated' ORDER BY [Id] DESC"); | |||||
| var select = document.getElementById("templateVersion"); | |||||
| while (select.options.length) select.remove(0); | |||||
| while (!rows.EOF) { | |||||
| var option = document.createElement("option"); | |||||
| option.value = String(rows.Fields("Id").Value); | |||||
| option.text = String(rows.Fields("TemplateName").Value) + " — version " + | |||||
| String(rows.Fields("VersionNumber").Value); | |||||
| select.add(option); | |||||
| rows.MoveNext(); | |||||
| } | |||||
| rows.Close(); | |||||
| } catch (error) { | |||||
| document.getElementById("templateMessage").innerText = "Template list failed: " + error.message; | |||||
| } finally { | |||||
| if (db !== null && db.State === 1) db.Close(); | |||||
| } | |||||
| } | |||||
| function runTemplateTool(scriptName, args) { | |||||
| var fs = new ActiveXObject("Scripting.FileSystemObject"); | |||||
| var script = fs.BuildPath(projectRoot(), "tools\\" + scriptName); | |||||
| var command = quoted(powerShellHost()) + " -NoProfile -ExecutionPolicy Bypass -File " + | |||||
| quoted(script) + " " + args; | |||||
| var process = new ActiveXObject("WScript.Shell").Exec(command); | |||||
| var output = process.StdOut.ReadAll(); | |||||
| var errors = process.StdErr.ReadAll(); | |||||
| if (process.ExitCode !== 0) throw new Error((output + errors).replace(/^\s+|\s+$/g, "")); | |||||
| return output.replace(/^\s+|\s+$/g, ""); | |||||
| } | |||||
| function importTemplate() { | |||||
| try { | |||||
| var path = document.getElementById("templatePath").value; | |||||
| if (!path || path.indexOf('"') !== -1) throw new Error("Select a template file"); | |||||
| var result = runTemplateTool("import-template.ps1", "-Path " + quoted(path)); | |||||
| document.getElementById("templateMessage").innerText = result; | |||||
| refreshTemplates(); | |||||
| refreshStatus(); | |||||
| } catch (error) { | |||||
| document.getElementById("templateMessage").innerText = "Import failed: " + error.message; | |||||
| } | |||||
| } | |||||
| function previewTemplate() { | |||||
| try { | |||||
| var id = document.getElementById("templateVersion").value; | |||||
| if (!/^[1-9][0-9]*$/.test(id)) throw new Error("Select an imported template"); | |||||
| var file = runTemplateTool("preview-template.ps1", "-TemplateId " + id); | |||||
| document.getElementById("templatePreview").src = file; | |||||
| document.getElementById("templateMessage").innerText = | |||||
| "Showing the imported snapshot. This preview does not include the campaign footer yet."; | |||||
| } catch (error) { | |||||
| document.getElementById("templateMessage").innerText = "Preview failed: " + error.message; | |||||
| } | |||||
| } | |||||
| </script> | </script> | ||||
| </head> | </head> | ||||
| <body onload="refreshStatus()"> | <body onload="refreshStatus()"> | ||||
| @@ -331,6 +390,16 @@ | |||||
| <table><thead><tr><th>CSV row</th><th>Email</th><th>Business</th><th>Source</th><th>Result</th></tr></thead> | <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> | <tbody id="previewBody"></tbody></table> | ||||
| </div> | </div> | ||||
| <div class="panel"> | |||||
| <h2>HTML templates</h2> | |||||
| <p>Choose a well-formed HTML file inside the configured template folder. Images must use relative PNG, JPEG, or GIF paths. Imports create a new version and copy image bytes into the database.</p> | |||||
| <label class="field">Template file <input id="templatePath" type="file" accept=".html,.htm" /></label> | |||||
| <button onclick="importTemplate()">Import template version</button> | |||||
| <label class="field">Imported version <select id="templateVersion"></select></label> | |||||
| <button onclick="previewTemplate()">Preview selected version</button> | |||||
| <div id="templateMessage"></div> | |||||
| <iframe id="templatePreview" APPLICATION="no" title="Template preview"></iframe> | |||||
| </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> | ||||
| @@ -7,6 +7,7 @@ Windows HTA application under construction for individual marketing emails for a | |||||
| - Creates an Access `.mdb` with versioned tables for contacts, suppressions, templates, campaigns, attempts, protected secrets, and audit records. | - 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. | - 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. | - Imports UTF-8 contacts from a previewed CSV and records global suppressions. | ||||
| - Imports constrained, well-formed HTML templates from `TEMPLATE_ROOT`, snapshots local PNG/JPEG/GIF images into the MDB, and previews the stored version in an untrusted HTA frame. | |||||
| - Stores the SMTP password as a Windows user-protected value in the MDB through an interactive local setup action. | - 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. | ||||
| @@ -19,12 +20,13 @@ The app sends no marketing messages in this build. Closing the HTA stops all in- | |||||
| 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. | 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. | |||||
| 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. `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. `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 | ## Planning documents | ||||
| @@ -0,0 +1,17 @@ | |||||
| # Template source folder | |||||
| Place your source `.html` or `.htm` files here or in subfolders. Importing a file stores a new version in the Access database. Images are copied into that version, so later edits to source files do not change an imported snapshot. Keep private campaign artwork under `templates/local/`, which Git ignores. | |||||
| The current importer accepts **well-formed HTML written as XML**. Close every tag and quote every attribute, including `<img ... />` and `<br />`. It accepts basic text, links, tables, and constrained inline `style` attributes. It rejects scripts, forms, external stylesheets, event handlers, remote images, SVG, and CSS image URLs. Image paths must be relative to the HTML file's folder and use PNG, JPEG, or GIF. | |||||
| Example: | |||||
| ```html | |||||
| <html><body> | |||||
| <h1 style="color:#17324e">9 x 12 postcards</h1> | |||||
| <p>See our local advertising options.</p> | |||||
| <img src="images/postcard.png" alt="Postcard example" width="600" /> | |||||
| </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. | |||||
| @@ -0,0 +1,72 @@ | |||||
| param( | |||||
| [Parameter(Mandatory = $true)][string]$Path, | |||||
| [string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini') | |||||
| ) | |||||
| $ErrorActionPreference = 'Stop' | |||||
| . (Join-Path $PSScriptRoot 'template-common.ps1') | |||||
| $context = Get-MailContext -ConfigPath $ConfigPath | |||||
| $limits = Get-TemplateLimits $context | |||||
| $sourcePath = [System.IO.Path]::GetFullPath($Path) | |||||
| if (-not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) { throw "Template not found: $sourcePath" } | |||||
| if (-not $sourcePath.StartsWith($limits.Root + [System.IO.Path]::DirectorySeparatorChar, | |||||
| [StringComparison]::OrdinalIgnoreCase)) { throw 'Template must be inside TEMPLATE_ROOT' } | |||||
| $templateName = [System.IO.Path]::GetFileNameWithoutExtension($sourcePath) | |||||
| if ($templateName.Length -gt 200) { throw 'Template file name is too long' } | |||||
| $document = Read-SafeTemplate $sourcePath | |||||
| $images = Get-TemplateImages $document $sourcePath $limits | |||||
| $html = $document.OuterXml | |||||
| $htmlBytes = [System.Text.Encoding]::UTF8.GetBytes($html) | |||||
| $total = $htmlBytes.Length | |||||
| foreach ($image in $images) { $total += $image.Bytes.Length } | |||||
| if ($total -gt $limits.MessageLimit) { throw 'Template and images exceed TEMPLATE_MESSAGE_MAX_BYTES' } | |||||
| $connection = Open-MailDatabase $context | |||||
| $transaction = $null | |||||
| try { | |||||
| $transaction = $connection.BeginTransaction() | |||||
| $next = $connection.CreateCommand() | |||||
| $next.Transaction = $transaction | |||||
| $next.CommandText = 'SELECT MAX([VersionNumber]) FROM [TemplateVersion] WHERE [TemplateName]=?' | |||||
| Add-TextParameter $next 'name' 200 $templateName | |||||
| $maximum = $next.ExecuteScalar() | |||||
| $version = if ($maximum -is [DBNull]) { 1 } else { [int]$maximum + 1 } | |||||
| $insert = $connection.CreateCommand() | |||||
| $insert.Transaction = $transaction | |||||
| $insert.CommandText = 'INSERT INTO [TemplateVersion] ([TemplateName],[VersionNumber],[HtmlContent],[ContentHash],[ImportedAtUtc],[ValidationStatus]) VALUES (?,?,?,?,?,?)' | |||||
| Add-TextParameter $insert 'name' 200 $templateName | |||||
| $null = $insert.Parameters.Add('version', [System.Data.OleDb.OleDbType]::Integer) | |||||
| $insert.Parameters[1].Value = $version | |||||
| $null = $insert.Parameters.Add('html', [System.Data.OleDb.OleDbType]::LongVarWChar) | |||||
| $insert.Parameters[2].Value = $html | |||||
| Add-TextParameter $insert 'hash' 64 (Get-HashHex $htmlBytes) | |||||
| $null = $insert.Parameters.Add('imported', [System.Data.OleDb.OleDbType]::Date) | |||||
| $insert.Parameters[4].Value = [DateTime]::UtcNow | |||||
| Add-TextParameter $insert 'status' 30 'validated' | |||||
| $null = $insert.ExecuteNonQuery() | |||||
| $identity = $connection.CreateCommand() | |||||
| $identity.Transaction = $transaction | |||||
| $identity.CommandText = 'SELECT @@IDENTITY' | |||||
| $templateId = [int]$identity.ExecuteScalar() | |||||
| foreach ($image in $images) { | |||||
| $command = $connection.CreateCommand() | |||||
| $command.Transaction = $transaction | |||||
| $command.CommandText = 'INSERT INTO [TemplateImage] ([TemplateVersionId],[OriginalRelativePath],[ContentType],[ContentHash],[SnapshotBytes],[ContentId]) VALUES (?,?,?,?,?,?)' | |||||
| $null = $command.Parameters.Add('template', [System.Data.OleDb.OleDbType]::Integer) | |||||
| $command.Parameters[0].Value = $templateId | |||||
| Add-TextParameter $command 'path' 255 $image.Path | |||||
| Add-TextParameter $command 'type' 40 $image.Type | |||||
| Add-TextParameter $command 'hash' 64 $image.Hash | |||||
| $null = $command.Parameters.Add('bytes', [System.Data.OleDb.OleDbType]::LongVarBinary, $image.Bytes.Length) | |||||
| $command.Parameters[4].Value = $image.Bytes | |||||
| Add-TextParameter $command 'cid' 200 $image.Cid | |||||
| $null = $command.ExecuteNonQuery() | |||||
| } | |||||
| $transaction.Commit() | |||||
| $transaction = $null | |||||
| Write-Output "Imported template ID $templateId, version $version, $($images.Count) inline image(s)." | |||||
| } | |||||
| catch { | |||||
| if ($transaction) { $transaction.Rollback() } | |||||
| throw | |||||
| } | |||||
| finally { $connection.Dispose() } | |||||
| @@ -0,0 +1,63 @@ | |||||
| param( | |||||
| [Parameter(Mandatory = $true)][int]$TemplateId, | |||||
| [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 'template-common.ps1') | |||||
| if ($TemplateId -lt 1) { throw 'TemplateId must be positive' } | |||||
| $context = Get-MailContext -ConfigPath $ConfigPath | |||||
| $connection = Open-MailDatabase $context | |||||
| try { | |||||
| $command = $connection.CreateCommand() | |||||
| $command.CommandText = 'SELECT [HtmlContent],[ContentHash],[ValidationStatus] FROM [TemplateVersion] WHERE [Id]=?' | |||||
| $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer) | |||||
| $command.Parameters[0].Value = $TemplateId | |||||
| $reader = $command.ExecuteReader() | |||||
| try { | |||||
| if (-not $reader.Read()) { throw "Template ID $TemplateId was not found" } | |||||
| $html = [string]$reader.GetValue(0) | |||||
| $hash = [string]$reader.GetValue(1) | |||||
| if ([string]$reader.GetValue(2) -ne 'validated' -or | |||||
| (Get-HashHex ([System.Text.Encoding]::UTF8.GetBytes($html))) -ne $hash) { | |||||
| throw 'Template validation or content hash failed' | |||||
| } | |||||
| } | |||||
| finally { $reader.Close() } | |||||
| $document = [xml]$html | |||||
| Assert-TemplateNode $document.DocumentElement | |||||
| $previewPath = Join-Path $OutputRoot ([guid]::NewGuid().ToString('N')) | |||||
| $null = New-Item -ItemType Directory -Path $previewPath -Force | |||||
| $command = $connection.CreateCommand() | |||||
| $command.CommandText = 'SELECT [OriginalRelativePath],[ContentType],[ContentHash],[SnapshotBytes],[ContentId] FROM [TemplateImage] WHERE [TemplateVersionId]=? ORDER BY [Id]' | |||||
| $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer) | |||||
| $command.Parameters[0].Value = $TemplateId | |||||
| $reader = $command.ExecuteReader() | |||||
| try { | |||||
| $index = 0 | |||||
| while ($reader.Read()) { | |||||
| $index++ | |||||
| $bytes = [byte[]]$reader.GetValue(3) | |||||
| if ((Get-HashHex $bytes) -ne [string]$reader.GetValue(2)) { throw 'Stored image hash failed' } | |||||
| $type = [string]$reader.GetValue(1) | |||||
| $ext = switch ($type) { 'image/png' { 'png' } 'image/jpeg' { 'jpg' } 'image/gif' { 'gif' } default { throw 'Unsupported stored image type' } } | |||||
| $name = "image-$index.$ext" | |||||
| [System.IO.File]::WriteAllBytes((Join-Path $previewPath $name), $bytes) | |||||
| $cid = 'cid:' + [string]$reader.GetValue(4) | |||||
| $found = $false | |||||
| foreach ($node in $document.GetElementsByTagName('img')) { | |||||
| if ($node.GetAttribute('src') -eq $cid) { $node.SetAttribute('src', $name); $found = $true } | |||||
| } | |||||
| if (-not $found) { throw 'Stored image is not referenced by the template' } | |||||
| } | |||||
| } | |||||
| finally { $reader.Close() } | |||||
| foreach ($node in $document.GetElementsByTagName('img')) { | |||||
| if ($node.GetAttribute('src').StartsWith('cid:')) { throw 'Template image snapshot is missing' } | |||||
| } | |||||
| $file = Join-Path $previewPath 'preview.html' | |||||
| [System.IO.File]::WriteAllText($file, $document.OuterXml, (New-Object System.Text.UTF8Encoding($false))) | |||||
| Write-Output $file | |||||
| } | |||||
| finally { $connection.Dispose() } | |||||
| @@ -0,0 +1,160 @@ | |||||
| $ErrorActionPreference = 'Stop' | |||||
| . (Join-Path $PSScriptRoot 'mail-common.ps1') | |||||
| function Get-TemplateLimits { | |||||
| param($Context) | |||||
| $settings = $Context.Settings | |||||
| if ($settings['TEMPLATE_IMAGE_MODE'] -ne 'inline_cid') { throw 'TEMPLATE_IMAGE_MODE must be inline_cid' } | |||||
| $types = @($settings['TEMPLATE_IMAGE_TYPES'].Split(',') | ForEach-Object { $_.Trim().ToLowerInvariant() }) | |||||
| if (-not $types.Count -or @($types | Where-Object { $_ -notin @('png', 'jpeg', 'gif') }).Count) { | |||||
| throw 'TEMPLATE_IMAGE_TYPES must contain only png, jpeg, and gif' | |||||
| } | |||||
| $imageLimit = 0 | |||||
| $messageLimit = 0 | |||||
| if (-not [int]::TryParse($settings['TEMPLATE_IMAGE_MAX_BYTES'], [ref]$imageLimit) -or $imageLimit -lt 1 -or $imageLimit -gt 10485760) { | |||||
| throw 'Invalid TEMPLATE_IMAGE_MAX_BYTES' | |||||
| } | |||||
| if (-not [int]::TryParse($settings['TEMPLATE_MESSAGE_MAX_BYTES'], [ref]$messageLimit) -or $messageLimit -lt $imageLimit -or $messageLimit -gt 52428800) { | |||||
| throw 'Invalid TEMPLATE_MESSAGE_MAX_BYTES' | |||||
| } | |||||
| $root = $settings['TEMPLATE_ROOT'] | |||||
| if (-not [System.IO.Path]::IsPathRooted($root)) { | |||||
| $root = Join-Path (Split-Path -Parent $PSScriptRoot) $root | |||||
| } | |||||
| return @{ Root = [System.IO.Path]::GetFullPath($root); Types = $types; | |||||
| ImageLimit = $imageLimit; MessageLimit = $messageLimit } | |||||
| } | |||||
| function Get-HashHex { | |||||
| param([byte[]]$Bytes) | |||||
| $hash = [System.Security.Cryptography.SHA256]::Create() | |||||
| try { return ([BitConverter]::ToString($hash.ComputeHash($Bytes))).Replace('-', '').ToLowerInvariant() } | |||||
| finally { $hash.Dispose() } | |||||
| } | |||||
| function Assert-SafeStyle { | |||||
| param([string]$Style) | |||||
| $allowed = @('color', 'background-color', 'font-family', 'font-size', 'font-weight', | |||||
| 'text-align', 'text-decoration', 'line-height', 'margin', 'margin-top', 'margin-bottom', | |||||
| 'padding', 'padding-top', 'padding-bottom', 'border', 'border-color', 'border-width', | |||||
| 'border-style', 'width', 'height', 'display') | |||||
| foreach ($declaration in $Style.Split(';')) { | |||||
| $part = $declaration.Trim() | |||||
| if (-not $part) { continue } | |||||
| $match = [regex]::Match($part, '^([a-z-]+)\s*:\s*(.+)$', 'IgnoreCase') | |||||
| if (-not $match.Success -or $match.Groups[1].Value.ToLowerInvariant() -notin $allowed) { | |||||
| throw "Unsupported CSS declaration: $part" | |||||
| } | |||||
| $value = $match.Groups[2].Value | |||||
| if ($value -notmatch "^[a-zA-Z0-9# ,.%()'-]+$" -or | |||||
| $value -match '(?i)(expression|url|behavior|import|javascript|vbscript|position|fixed)') { | |||||
| throw "Unsafe CSS value: $part" | |||||
| } | |||||
| } | |||||
| } | |||||
| function Assert-TemplateNode { | |||||
| param([System.Xml.XmlNode]$Node) | |||||
| if ($Node.NodeType -eq [System.Xml.XmlNodeType]::Comment) { throw 'HTML comments are not supported' } | |||||
| if ($Node.NodeType -eq [System.Xml.XmlNodeType]::Text -or | |||||
| $Node.NodeType -eq [System.Xml.XmlNodeType]::Whitespace -or | |||||
| $Node.NodeType -eq [System.Xml.XmlNodeType]::SignificantWhitespace) { return } | |||||
| if ($Node.NodeType -ne [System.Xml.XmlNodeType]::Element) { throw 'Unsupported HTML node' } | |||||
| $tags = @('html', 'head', 'title', 'body', 'div', 'span', 'p', 'br', 'hr', 'h1', 'h2', | |||||
| 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'b', 'i', 'u', 'ul', 'ol', 'li', 'table', | |||||
| 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'a', 'img') | |||||
| $tag = $Node.LocalName.ToLowerInvariant() | |||||
| if ($Node.NamespaceURI -and $Node.NamespaceURI -ne 'http://www.w3.org/1999/xhtml') { | |||||
| throw "Unsupported namespace on <$tag>" | |||||
| } | |||||
| if ($tag -notin $tags) { throw "Unsupported HTML element: <$tag>" } | |||||
| foreach ($attribute in $Node.Attributes) { | |||||
| $name = $attribute.Name.ToLowerInvariant() | |||||
| $value = $attribute.Value | |||||
| if ($name -eq 'xmlns' -and $tag -eq 'html' -and | |||||
| $value -eq 'http://www.w3.org/1999/xhtml') { continue } | |||||
| if ($name -eq 'style') { Assert-SafeStyle $value; continue } | |||||
| if ($name -eq 'class' -and $value -match '^[a-zA-Z0-9_ -]{1,100}$') { continue } | |||||
| if ($name -eq 'alt' -and $tag -eq 'img') { continue } | |||||
| if ($name -eq 'src' -and $tag -eq 'img') { continue } | |||||
| if ($name -eq 'href' -and $tag -eq 'a' -and | |||||
| $value -match '^(https://[^\s<>"'']+|mailto:[^\s<>"'']+)$') { continue } | |||||
| if ($name -in @('width', 'height', 'border', 'cellpadding', 'cellspacing') -and | |||||
| $value -match '^[0-9]{1,4}(%|px)?$') { continue } | |||||
| if ($name -eq 'align' -and $value -in @('left', 'center', 'right')) { continue } | |||||
| if ($name -eq 'valign' -and $value -in @('top', 'middle', 'bottom')) { continue } | |||||
| if ($name -eq 'bgcolor' -and $value -match '^#[0-9a-fA-F]{6}$') { continue } | |||||
| throw "Unsupported or unsafe attribute $name on <$tag>" | |||||
| } | |||||
| foreach ($child in $Node.ChildNodes) { Assert-TemplateNode $child } | |||||
| } | |||||
| function Read-SafeTemplate { | |||||
| param([string]$Path) | |||||
| if ([System.IO.Path]::GetExtension($Path).ToLowerInvariant() -notin @('.html', '.htm')) { | |||||
| throw 'Select a .html or .htm template' | |||||
| } | |||||
| $readerSettings = New-Object System.Xml.XmlReaderSettings | |||||
| $readerSettings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit | |||||
| $readerSettings.XmlResolver = $null | |||||
| $readerSettings.MaxCharactersInDocument = 2097152 | |||||
| $reader = [System.Xml.XmlReader]::Create($Path, $readerSettings) | |||||
| try { | |||||
| $document = New-Object System.Xml.XmlDocument | |||||
| $document.XmlResolver = $null | |||||
| $document.Load($reader) | |||||
| } | |||||
| finally { $reader.Dispose() } | |||||
| if ($document.DocumentElement.LocalName.ToLowerInvariant() -ne 'html') { | |||||
| throw 'Template must have an <html> root' | |||||
| } | |||||
| Assert-TemplateNode $document.DocumentElement | |||||
| if (-not @($document.DocumentElement.ChildNodes | Where-Object { $_.LocalName -eq 'body' }).Count) { | |||||
| throw 'Template must have a <body> element' | |||||
| } | |||||
| return $document | |||||
| } | |||||
| function Get-TemplateImages { | |||||
| param($Document, [string]$TemplatePath, $Limits) | |||||
| $base = [System.IO.Path]::GetDirectoryName($TemplatePath) | |||||
| $images = New-Object System.Collections.ArrayList | |||||
| $seen = @{} | |||||
| foreach ($node in $Document.GetElementsByTagName('img')) { | |||||
| $relative = $node.GetAttribute('src') | |||||
| if (-not $relative -or $relative -notmatch '^[a-zA-Z0-9_][a-zA-Z0-9_./ -]*$' -or | |||||
| $relative -match '(^|/)\.\.?(/|$)' -or $relative.Contains('//')) { | |||||
| throw "Image src must be a simple relative path: $relative" | |||||
| } | |||||
| $imagePath = [System.IO.Path]::GetFullPath((Join-Path $base $relative)) | |||||
| if (-not $imagePath.StartsWith($base + [System.IO.Path]::DirectorySeparatorChar, | |||||
| [StringComparison]::OrdinalIgnoreCase)) { throw "Image is outside template folder: $relative" } | |||||
| if (-not (Test-Path -LiteralPath $imagePath -PathType Leaf)) { throw "Missing image: $relative" } | |||||
| $ext = [System.IO.Path]::GetExtension($imagePath).ToLowerInvariant() | |||||
| $type = switch ($ext) { '.png' { 'png' } '.jpg' { 'jpeg' } '.jpeg' { 'jpeg' } '.gif' { 'gif' } default { '' } } | |||||
| if ($type -notin $Limits.Types) { throw "Image type is not enabled: $relative" } | |||||
| $length = (Get-Item -LiteralPath $imagePath).Length | |||||
| if ($length -lt 8 -or $length -gt $Limits.ImageLimit) { throw "Image size is outside the configured limit: $relative" } | |||||
| if (-not $seen.ContainsKey($relative)) { | |||||
| $bytes = [System.IO.File]::ReadAllBytes($imagePath) | |||||
| $valid = switch ($type) { | |||||
| 'png' { $bytes.Length -ge 8 -and ([BitConverter]::ToString($bytes,0,8) -eq '89-50-4E-47-0D-0A-1A-0A') } | |||||
| 'jpeg' { $bytes.Length -ge 4 -and $bytes[0] -eq 255 -and $bytes[1] -eq 216 -and $bytes[-2] -eq 255 -and $bytes[-1] -eq 217 } | |||||
| 'gif' { $bytes.Length -ge 6 -and [System.Text.Encoding]::ASCII.GetString($bytes,0,6) -in @('GIF87a','GIF89a') } | |||||
| } | |||||
| if (-not $valid) { throw "Image signature does not match its type: $relative" } | |||||
| $cid = 'mm-' + [guid]::NewGuid().ToString('N') + '@local' | |||||
| $seen[$relative] = $cid | |||||
| $null = $images.Add(@{ Path = $relative; Bytes = $bytes; Type = 'image/' + $type; | |||||
| Hash = Get-HashHex $bytes; Cid = $cid }) | |||||
| } | |||||
| $node.SetAttribute('src', 'cid:' + $seen[$relative]) | |||||
| } | |||||
| return ,$images | |||||
| } | |||||
| function Add-TextParameter { | |||||
| param($Command, [string]$Name, [int]$Size, [string]$Value) | |||||
| $null = $Command.Parameters.Add($Name, [System.Data.OleDb.OleDbType]::VarWChar, $Size) | |||||
| $Command.Parameters[$Command.Parameters.Count - 1].Value = $Value | |||||
| } | |||||
| @@ -0,0 +1,14 @@ | |||||
| $ErrorActionPreference = 'Stop' | |||||
| $root = Split-Path -Parent $PSScriptRoot | |||||
| $hta = Get-Content -LiteralPath (Join-Path $root 'MassMailer.hta') -Raw | |||||
| $match = [regex]::Match($hta, '(?s)<script language="JScript" type="text/javascript">(.*?)</script>') | |||||
| if (-not $match.Success) { throw 'HTA JScript block not found' } | |||||
| $check = Join-Path $root ('temp\hta-syntax-' + [guid]::NewGuid().ToString('N') + '.js') | |||||
| try { | |||||
| [System.IO.File]::WriteAllText($check, $match.Groups[1].Value) | |||||
| $scriptHost = Join-Path $env:SystemRoot 'SysWOW64\cscript.exe' | |||||
| & $scriptHost //nologo //E:JScript $check | |||||
| if ($LASTEXITCODE -ne 0) { throw 'HTA JScript syntax failed' } | |||||
| Write-Output 'HTA JScript syntax passed' | |||||
| } | |||||
| finally { if (Test-Path -LiteralPath $check) { Remove-Item -LiteralPath $check -Force } } | |||||
| @@ -0,0 +1,63 @@ | |||||
| $ErrorActionPreference = 'Stop' | |||||
| $projectRoot = Split-Path -Parent $PSScriptRoot | |||||
| $workPath = Join-Path $projectRoot ('temp\template-flow-' + [guid]::NewGuid().ToString('N')) | |||||
| $templateRoot = Join-Path $workPath 'templates' | |||||
| $assetRoot = Join-Path $templateRoot 'images' | |||||
| $databasePath = Join-Path $workPath 'test.mdb' | |||||
| $configPath = Join-Path $workPath 'test.ini' | |||||
| $previewRoot = Join-Path $workPath 'previews' | |||||
| $templatePath = Join-Path $templateRoot 'postcard.html' | |||||
| $imagePath = Join-Path $assetRoot 'logo.png' | |||||
| $scriptHost = Join-Path $env:SystemRoot 'SysWOW64\cscript.exe' | |||||
| $powerShellX86 = Join-Path $env:SystemRoot 'SysWOW64\WindowsPowerShell\v1.0\powershell.exe' | |||||
| try { | |||||
| $null = New-Item -ItemType Directory -Path $assetRoot -Force | |||||
| $example = Get-Content -LiteralPath (Join-Path $projectRoot 'config.example.ini') -Raw | |||||
| $example.Replace('DB_PATH=data/MassMailer.mdb', ('DB_PATH=' + $databasePath)).Replace( | |||||
| 'TEMPLATE_ROOT=templates', ('TEMPLATE_ROOT=' + $templateRoot)) | | |||||
| Set-Content -LiteralPath $configPath -Encoding UTF8 | |||||
| & $scriptHost //nologo (Join-Path $PSScriptRoot 'init-db.vbs') $configPath | |||||
| if ($LASTEXITCODE -ne 0) { throw 'MDB initialization failed' } | |||||
| $png = [Convert]::FromBase64String('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=') | |||||
| [System.IO.File]::WriteAllBytes($imagePath, $png) | |||||
| $safe = '<html><body><h1 style="color:#112233; font-size:20px">Postcard</h1><p>See our offer.</p><img src="images/logo.png" width="1" height="1" alt="Logo" /><a href="https://example.invalid">Details</a></body></html>' | |||||
| [System.IO.File]::WriteAllText($templatePath, $safe, [System.Text.Encoding]::UTF8) | |||||
| & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'import-template.ps1') -Path $templatePath -ConfigPath $configPath | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Valid template import failed' } | |||||
| Remove-Item -LiteralPath $imagePath -Force | |||||
| $preview = & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'preview-template.ps1') -TemplateId 1 -ConfigPath $configPath -OutputRoot $previewRoot | |||||
| if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $preview -PathType Leaf)) { throw 'Snapshot preview failed after source image deletion' } | |||||
| $rendered = Get-Content -LiteralPath $preview -Raw | |||||
| if ($rendered -notmatch 'image-1.png' -or $rendered -match 'cid:|file:|<script') { throw 'Preview contains an unsafe or missing image reference' } | |||||
| $savedImage = Join-Path (Split-Path -Parent $preview) 'image-1.png' | |||||
| if (([Convert]::ToBase64String([System.IO.File]::ReadAllBytes($savedImage))) -ne ([Convert]::ToBase64String($png))) { | |||||
| throw 'Preview image differs from imported snapshot' | |||||
| } | |||||
| foreach ($invalid in @( | |||||
| $safe.Replace('<p>See our offer.</p>', '<script>alert(1)</script>'), | |||||
| $safe.Replace('images/logo.png', '../secret.png'), | |||||
| $safe.Replace('color:#112233', 'background-image:url(file:///C:/secret)') | |||||
| )) { | |||||
| [System.IO.File]::WriteAllText($templatePath, $invalid, [System.Text.Encoding]::UTF8) | |||||
| $ErrorActionPreference = 'Continue' | |||||
| $null = & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'import-template.ps1') -Path $templatePath -ConfigPath $configPath 2>$null | |||||
| $rejected = $LASTEXITCODE -ne 0 | |||||
| $ErrorActionPreference = 'Stop' | |||||
| if (-not $rejected) { throw 'Unsafe template was accepted' } | |||||
| } | |||||
| & $powerShellX86 -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'test-template-state.ps1') -ConfigPath $configPath | |||||
| if ($LASTEXITCODE -ne 0) { throw 'Invalid imports changed the template library' } | |||||
| Write-Output 'Template import, image snapshot, safe preview, and rejection checks passed' | |||||
| } | |||||
| finally { | |||||
| $resolvedWork = [System.IO.Path]::GetFullPath($workPath) | |||||
| $resolvedTemp = [System.IO.Path]::GetFullPath((Join-Path $projectRoot 'temp')) | |||||
| if ($resolvedWork.StartsWith($resolvedTemp + [System.IO.Path]::DirectorySeparatorChar, | |||||
| [StringComparison]::OrdinalIgnoreCase) -and (Test-Path -LiteralPath $resolvedWork)) { | |||||
| Remove-Item -LiteralPath $resolvedWork -Recurse -Force | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| param([Parameter(Mandatory = $true)][string]$ConfigPath) | |||||
| $ErrorActionPreference = 'Stop' | |||||
| . (Join-Path $PSScriptRoot 'mail-common.ps1') | |||||
| $context = Get-MailContext -ConfigPath $ConfigPath | |||||
| $db = Open-MailDatabase $context | |||||
| try { | |||||
| $command = $db.CreateCommand() | |||||
| $command.CommandText = 'SELECT COUNT(*) FROM [TemplateVersion]' | |||||
| if ([int]$command.ExecuteScalar() -ne 1) { throw 'Expected exactly one validated template' } | |||||
| Write-Output 'Invalid imports left template count unchanged' | |||||
| } | |||||
| finally { $db.Dispose() } | |||||
Powered by TurnKey Linux.