$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 16384) { 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' } # Absent ContactIds means every eligible contact; a present list limits the campaign to those ids. $contactIds = $null if ($null -ne $request.ContactIds) { $seen = @{} foreach ($value in @($request.ContactIds)) { $contactId = 0 if (-not [int]::TryParse([string]$value, [ref]$contactId) -or $contactId -lt 1) { throw 'Invalid contact selection' } $seen[$contactId] = $true } if ($seen.Count -lt 1) { throw 'Select at least one recipient' } $contactIds = @($seen.Keys | Sort-Object) } return @{ Name = $name; Subject = $subject; TemplateId = $id; ContactIds = $contactIds; 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, $ContactIds = $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 ($null -ne $ContactIds) { $eligible = @{} foreach ($contact in $contacts) { $eligible[$contact.Id] = $contact } $selected = New-Object System.Collections.ArrayList foreach ($contactId in $ContactIds) { if (-not $eligible.ContainsKey([int]$contactId)) { throw 'A selected contact is no longer eligible or was suppressed. Review again.' } $null = $selected.Add($eligible[[int]$contactId]) } $contacts = $selected } 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 ' } $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 $Request.ContactIds $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() }