diff --git a/.specs/features/mass-mailer/tasks.md b/.specs/features/mass-mailer/tasks.md index 0b89d15..dc942cc 100644 --- a/.specs/features/mass-mailer/tasks.md +++ b/.specs/features/mass-mailer/tasks.md @@ -10,7 +10,7 @@ | F04 ✅ | HTA status shell | F03 | Hidden-window smoke and scripted status query passed; no campaign send process exists. | | F05 ◐ | SMTP transport and protected secret feasibility | F02 | STARTTLS 1.3, DPAPI round trip/rotation, and inline-image pickup MIME pass; authenticated internal send and inbox check pending. | | F06 ✅ | Contact import and suppression controls | F02, F04 | Disposable MDB integration test passes import, duplicate rejection, re-import rejection, and suppression. | -| F07 | HTML template import and safe preview | F02, F04 | Local images snapshot and preview; unsafe paths and active content fail. | +| 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. | | 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. | diff --git a/.specs/project/STATE.md b/.specs/project/STATE.md index bf018c3..098ccc4 100644 --- a/.specs/project/STATE.md +++ b/.specs/project/STATE.md @@ -2,7 +2,7 @@ ## 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 @@ -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 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. diff --git a/MassMailer.hta b/MassMailer.hta index aa45d91..df7c5e6 100644 --- a/MassMailer.hta +++ b/MassMailer.hta @@ -27,6 +27,7 @@ .bad { color: #9c2929; } .good { color: #235e37; } #error { color: #9c2929; white-space: pre-wrap; margin-top: 12px; } + #templatePreview { width: 100%; height: 440px; border: 1px solid #dbe1e7; background: white; margin-top: 12px; } @@ -331,6 +390,16 @@
CSV rowEmailBusinessSourceResult
+
+

HTML templates

+

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.

+ + + + +
+ +

Suppress an address

Add unsubscribe replies here before the next send. Suppression applies across all campaigns.

diff --git a/README.md b/README.md index d06da5f..89e8f8d 100644 --- a/README.md +++ b/README.md @@ -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. - 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 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. - 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. 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. Local `config.ini`, MDB files, logs, backups, and temporary test data are ignored by Git. ## Verification -Run `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-contact-flow.ps1` for a disposable MDB integration check. It covers database initialization and preflight, protected password save/load/rotation using synthetic values, local MIME pickup with an inline image, contact import, duplicate rejection, and suppression. `C:\Windows\SysWOW64\cscript.exe //nologo //E:JScript tools\test-contacts.js` covers CSV parsing and validation. `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\test-smtp-capabilities.ps1` checks the configured server's STARTTLS handshake without authenticating or sending mail. +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 diff --git a/templates/README.md b/templates/README.md new file mode 100644 index 0000000..aa2d8ee --- /dev/null +++ b/templates/README.md @@ -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 `` and `
`. 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 + +

9 x 12 postcards

+

See our local advertising options.

+ Postcard example + +``` + +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. diff --git a/tools/import-template.ps1 b/tools/import-template.ps1 new file mode 100644 index 0000000..221edc8 --- /dev/null +++ b/tools/import-template.ps1 @@ -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() } diff --git a/tools/preview-template.ps1 b/tools/preview-template.ps1 new file mode 100644 index 0000000..ba660e4 --- /dev/null +++ b/tools/preview-template.ps1 @@ -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() } diff --git a/tools/template-common.ps1 b/tools/template-common.ps1 new file mode 100644 index 0000000..eb6a40e --- /dev/null +++ b/tools/template-common.ps1 @@ -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 root' + } + Assert-TemplateNode $document.DocumentElement + if (-not @($document.DocumentElement.ChildNodes | Where-Object { $_.LocalName -eq 'body' }).Count) { + throw 'Template must have a 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 +} diff --git a/tools/test-hta-syntax.ps1 b/tools/test-hta-syntax.ps1 new file mode 100644 index 0000000..068f1bd --- /dev/null +++ b/tools/test-hta-syntax.ps1 @@ -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)') +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 } } diff --git a/tools/test-template-flow.ps1 b/tools/test-template-flow.ps1 new file mode 100644 index 0000000..4d775ef --- /dev/null +++ b/tools/test-template-flow.ps1 @@ -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 = '

Postcard

See our offer.

LogoDetails' + [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:|See our offer.

', ''), + $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 + } +} diff --git a/tools/test-template-state.ps1 b/tools/test-template-state.ps1 new file mode 100644 index 0000000..97c5061 --- /dev/null +++ b/tools/test-template-state.ps1 @@ -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() }