|
- $ErrorActionPreference = 'Stop'
- . (Join-Path $PSScriptRoot 'template-common.ps1')
-
- function Read-CampaignRequest {
- param([string]$Path)
- if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw 'Campaign request file not found' }
- if ((Get-Item -LiteralPath $Path).Length -gt 8192) { throw 'Campaign request is too large' }
- $request = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
- $name = ([string]$request.Name).Trim()
- $subject = ([string]$request.Subject).Trim()
- $id = 0
- if (-not [int]::TryParse([string]$request.TemplateId, [ref]$id) -or $id -lt 1) {
- throw 'Select a template version'
- }
- if ($name.Length -lt 1 -or $name.Length -gt 200 -or $name -match '[\x00-\x1f]') {
- throw 'Campaign name must be 1-200 characters without control characters'
- }
- if ($subject.Length -lt 1 -or $subject.Length -gt 240 -or $subject -match '[\x00-\x1f]') {
- throw 'Subject must be 1-240 characters without control characters'
- }
- return @{ Name = $name; Subject = $subject; TemplateId = $id;
- ReviewToken = ([string]$request.ReviewToken).Trim(); TestReceived = ($request.TestReceived -eq $true) }
- }
-
- function Get-CampaignIdentity {
- param($Context)
- $settings = $Context.Settings
- foreach ($key in @('SMTP_FROM','SMTP_REPLY_TO','SMTP_TEST_TO','BUSINESS_POSTAL_ADDRESS')) {
- if (-not $settings[$key] -or -not $settings[$key].Trim()) { throw "Set $key in config.ini first" }
- }
- $from = $settings['SMTP_FROM'].Trim()
- $replyTo = $settings['SMTP_REPLY_TO'].Trim()
- $testTo = $settings['SMTP_TEST_TO'].Trim()
- foreach ($address in @($from, $replyTo, $testTo)) {
- $parsed = New-Object System.Net.Mail.MailAddress($address)
- if ($parsed.Address -ne $address) { throw 'SMTP addresses must each be a single bare email address' }
- }
- $postal = $settings['BUSINESS_POSTAL_ADDRESS'].Trim()
- if ($postal.Length -gt 500 -or $postal -match '[\x00-\x1f]') {
- throw 'BUSINESS_POSTAL_ADDRESS must be one line of at most 500 characters'
- }
- return @{ From = $from; ReplyTo = $replyTo; TestTo = $testTo; Postal = $postal }
- }
-
- function New-DbCommand {
- param($Connection, [string]$Sql, $Transaction = $null)
- $command = $Connection.CreateCommand()
- $command.CommandText = $Sql
- if ($Transaction) { $command.Transaction = $Transaction }
- return $command
- }
-
- function Get-EligibleContacts {
- param($Connection, $Transaction = $null)
- $command = New-DbCommand $Connection "SELECT c.[Id],c.[EmailOriginal],c.[EmailKey] FROM [Contact] AS c LEFT JOIN [Suppression] AS s ON c.[EmailKey]=s.[EmailKey] WHERE s.[EmailKey] IS NULL AND c.[Country]='US' AND c.[EligibilityBasis]='public_listing' ORDER BY c.[Id]" $Transaction
- $reader = $command.ExecuteReader()
- $contacts = New-Object System.Collections.ArrayList
- try {
- while ($reader.Read()) {
- $null = $contacts.Add(@{ Id = [int]$reader.GetValue(0);
- Email = [string]$reader.GetValue(1); Key = [string]$reader.GetValue(2) })
- }
- }
- finally { $reader.Dispose() }
- if ($contacts.Count -lt 1) { throw 'No eligible, unsuppressed US contacts are available' }
- if ($contacts.Count -gt 500) { throw 'This version supports at most 500 eligible contacts per campaign' }
- return ,$contacts
- }
-
- function Get-TemplateSnapshot {
- param($Connection, [int]$TemplateId, $Transaction = $null)
- $command = New-DbCommand $Connection 'SELECT [TemplateName],[VersionNumber],[HtmlContent],[ContentHash],[ValidationStatus] FROM [TemplateVersion] WHERE [Id]=?' $Transaction
- $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
- $command.Parameters[0].Value = $TemplateId
- $reader = $command.ExecuteReader()
- try {
- if (-not $reader.Read()) { throw 'Selected template version was not found' }
- $name = [string]$reader.GetValue(0)
- $version = [int]$reader.GetValue(1)
- $html = [string]$reader.GetValue(2)
- $hash = [string]$reader.GetValue(3)
- if ([string]$reader.GetValue(4) -ne 'validated' -or
- (Get-HashHex ([System.Text.Encoding]::UTF8.GetBytes($html))) -ne $hash) {
- throw 'Template validation or content hash failed'
- }
- }
- finally { $reader.Dispose() }
- $document = Read-SafeTemplateXml $html $true
- $images = New-Object System.Collections.ArrayList
- $command = New-DbCommand $Connection 'SELECT [ContentType],[ContentHash],[SnapshotBytes],[ContentId] FROM [TemplateImage] WHERE [TemplateVersionId]=? ORDER BY [Id]' $Transaction
- $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
- $command.Parameters[0].Value = $TemplateId
- $reader = $command.ExecuteReader()
- try {
- while ($reader.Read()) {
- $bytes = [byte[]]$reader.GetValue(2)
- $cid = [string]$reader.GetValue(3)
- $type = [string]$reader.GetValue(0)
- if ((Get-HashHex $bytes) -ne [string]$reader.GetValue(1) -or
- $cid -notmatch '^mm-[0-9a-f]{32}@local$' -or
- $type -notin @('image/png','image/jpeg','image/gif')) {
- throw 'Stored template image failed validation'
- }
- $null = $images.Add(@{ Bytes = $bytes; Cid = $cid; Type = $type })
- }
- }
- finally { $reader.Dispose() }
- $references = @{}
- foreach ($image in $images) { $references['cid:' + $image.Cid] = $false }
- foreach ($node in $document.GetElementsByTagName('img')) {
- $src = $node.GetAttribute('src')
- if (-not $references.ContainsKey($src)) { throw 'Template image reference has no stored snapshot' }
- $references[$src] = $true
- }
- foreach ($entry in $references.GetEnumerator()) {
- if (-not $entry.Value) { throw 'Stored template image has no HTML reference' }
- }
- return @{ Name = $name; Version = $version; Html = $html; Hash = $hash;
- Document = $document; Images = $images }
- }
-
- function Add-CampaignFooter {
- param($Document, $Identity)
- $body = @($Document.DocumentElement.ChildNodes | Where-Object { $_.LocalName -eq 'body' })[0]
- if (-not $body) { throw 'Stored template is missing <body>' }
- $ns = $Document.DocumentElement.NamespaceURI
- $divider = $Document.CreateElement('hr', $ns)
- $null = $body.AppendChild($divider)
- $footer = $Document.CreateElement('div', $ns)
- $footer.SetAttribute('style', 'font-size:12px; color:#444444')
- foreach ($line in @(
- 'Advertisement from ' + $Identity.From + '.',
- $Identity.Postal,
- 'To stop receiving marketing emails, reply to ' + $Identity.ReplyTo + ' with UNSUBSCRIBE in the subject.'
- )) {
- $paragraph = $Document.CreateElement('p', $ns)
- $null = $paragraph.AppendChild($Document.CreateTextNode($line))
- $null = $footer.AppendChild($paragraph)
- }
- $null = $body.AppendChild($footer)
- return $Document.OuterXml
- }
-
- function Get-CampaignReview {
- param($Context, $Request, $Connection, $Transaction = $null)
- $identity = Get-CampaignIdentity $Context
- $template = Get-TemplateSnapshot $Connection $Request.TemplateId $Transaction
- $contacts = Get-EligibleContacts $Connection $Transaction
- $html = Add-CampaignFooter $template.Document $identity
- $parts = New-Object System.Collections.ArrayList
- foreach ($value in @($Request.Name, $Request.Subject, [string]$Request.TemplateId,
- $template.Hash, $identity.From, $identity.ReplyTo, $identity.Postal)) {
- $null = $parts.Add($value)
- }
- foreach ($contact in $contacts) { $null = $parts.Add(([string]$contact.Id + ':' + $contact.Key)) }
- $token = Get-HashHex ([System.Text.Encoding]::UTF8.GetBytes(($parts -join [char]0)))
- return @{ Name = $Request.Name; Subject = $Request.Subject; TemplateId = $Request.TemplateId;
- TemplateName = $template.Name; TemplateVersion = $template.Version; Identity = $identity;
- Contacts = $contacts; Html = $html; Images = $template.Images; Token = $token }
- }
-
- function Assert-ReviewToken {
- param($Request, $Review)
- if ($Request.ReviewToken -notmatch '^[0-9a-f]{64}$' -or $Request.ReviewToken -ne $Review.Token) {
- throw 'Campaign inputs, recipients, template, or settings changed. Review again.'
- }
- }
-
- function Write-CampaignPreview {
- param($Review, [string]$OutputRoot)
- $folder = Join-Path $OutputRoot ([guid]::NewGuid().ToString('N'))
- $null = New-Item -ItemType Directory -Path $folder -Force
- $document = Read-SafeTemplateXml $Review.Html $true
- $index = 0
- foreach ($image in $Review.Images) {
- $index++
- $extension = switch ($image.Type) { 'image/png' { 'png' } 'image/jpeg' { 'jpg' } 'image/gif' { 'gif' } }
- $filename = "image-$index.$extension"
- [System.IO.File]::WriteAllBytes((Join-Path $folder $filename), $image.Bytes)
- foreach ($node in $document.GetElementsByTagName('img')) {
- if ($node.GetAttribute('src') -eq ('cid:' + $image.Cid)) { $node.SetAttribute('src', $filename) }
- }
- }
- $file = Join-Path $folder 'preview.html'
- [System.IO.File]::WriteAllText($file, $document.OuterXml, (New-Object System.Text.UTF8Encoding($false)))
- return $file
- }
-
- function Write-CampaignAudit {
- param($Connection, [string]$Action, [string]$Token, [string]$Detail, $Transaction = $null)
- $command = New-DbCommand $Connection 'INSERT INTO [AuditEvent] ([Actor],[Action],[EntityId],[TimestampUtc],[Detail]) VALUES (?,?,?,?,?)' $Transaction
- Add-TextParameter $command 'actor' 100 'local_operator'
- Add-TextParameter $command 'action' 100 $Action
- Add-TextParameter $command 'entity' 100 $Token
- $null = $command.Parameters.Add('at', [System.Data.OleDb.OleDbType]::Date)
- $command.Parameters[3].Value = [DateTime]::UtcNow
- Add-TextParameter $command 'detail' 255 $Detail
- $null = $command.ExecuteNonQuery()
- }
|