Przeglądaj źródła

Add safe HTML template import and image snapshots

master
Daniel Covington 2 dni temu
rodzic
commit
e5c78289ec
11 zmienionych plików z 476 dodań i 4 usunięć
  1. +1
    -1
      .specs/features/mass-mailer/tasks.md
  2. +2
    -2
      .specs/project/STATE.md
  3. +69
    -0
      MassMailer.hta
  4. +3
    -1
      README.md
  5. +17
    -0
      templates/README.md
  6. +72
    -0
      tools/import-template.ps1
  7. +63
    -0
      tools/preview-template.ps1
  8. +160
    -0
      tools/template-common.ps1
  9. +14
    -0
      tools/test-hta-syntax.ps1
  10. +63
    -0
      tools/test-template-flow.ps1
  11. +12
    -0
      tools/test-template-state.ps1

+ 1
- 1
.specs/features/mass-mailer/tasks.md Wyświetl plik

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


+ 2
- 2
.specs/project/STATE.md Wyświetl plik

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

+ 69
- 0
MassMailer.hta Wyświetl plik

@@ -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; }
</style>
<script language="JScript" src="src/contacts.js"></script>
<script language="JScript" type="text/javascript">
@@ -91,6 +92,7 @@
document.getElementById("interval").innerText = opened.config.QUEUE_INTERVAL + " seconds";
document.getElementById("testTo").innerText = opened.config.SMTP_TEST_TO || "Not configured";
document.getElementById("error").innerText = "";
refreshTemplates();
} catch (error) {
document.getElementById("error").innerText = "Status refresh failed: " + error.message;
} finally {
@@ -290,6 +292,63 @@
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>
</head>
<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>
<tbody id="previewBody"></tbody></table>
</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">
<h2>Suppress an address</h2>
<p>Add unsubscribe replies here before the next send. Suppression applies across all campaigns.</p>


+ 3
- 1
README.md Wyświetl plik

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



+ 17
- 0
templates/README.md Wyświetl plik

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

+ 72
- 0
tools/import-template.ps1 Wyświetl plik

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

+ 63
- 0
tools/preview-template.ps1 Wyświetl plik

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

+ 160
- 0
tools/template-common.ps1 Wyświetl plik

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

+ 14
- 0
tools/test-hta-syntax.ps1 Wyświetl plik

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

+ 63
- 0
tools/test-template-flow.ps1 Wyświetl plik

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

+ 12
- 0
tools/test-template-state.ps1 Wyświetl plik

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

Ładowanie…
Anuluj
Zapisz

Powered by TurnKey Linux.