Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

226 рядки
11KB

  1. $ErrorActionPreference = 'Stop'
  2. . (Join-Path $PSScriptRoot 'template-common.ps1')
  3. function Read-CampaignRequest {
  4. param([string]$Path)
  5. if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw 'Campaign request file not found' }
  6. if ((Get-Item -LiteralPath $Path).Length -gt 16384) { throw 'Campaign request is too large' }
  7. $request = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json
  8. $name = ([string]$request.Name).Trim()
  9. $subject = ([string]$request.Subject).Trim()
  10. $id = 0
  11. if (-not [int]::TryParse([string]$request.TemplateId, [ref]$id) -or $id -lt 1) {
  12. throw 'Select a template version'
  13. }
  14. if ($name.Length -lt 1 -or $name.Length -gt 200 -or $name -match '[\x00-\x1f]') {
  15. throw 'Campaign name must be 1-200 characters without control characters'
  16. }
  17. if ($subject.Length -lt 1 -or $subject.Length -gt 240 -or $subject -match '[\x00-\x1f]') {
  18. throw 'Subject must be 1-240 characters without control characters'
  19. }
  20. # Absent ContactIds means every eligible contact; a present list limits the campaign to those ids.
  21. $contactIds = $null
  22. if ($null -ne $request.ContactIds) {
  23. $seen = @{}
  24. foreach ($value in @($request.ContactIds)) {
  25. $contactId = 0
  26. if (-not [int]::TryParse([string]$value, [ref]$contactId) -or $contactId -lt 1) {
  27. throw 'Invalid contact selection'
  28. }
  29. $seen[$contactId] = $true
  30. }
  31. if ($seen.Count -lt 1) { throw 'Select at least one recipient' }
  32. $contactIds = @($seen.Keys | Sort-Object)
  33. }
  34. return @{ Name = $name; Subject = $subject; TemplateId = $id; ContactIds = $contactIds;
  35. ReviewToken = ([string]$request.ReviewToken).Trim(); TestReceived = ($request.TestReceived -eq $true) }
  36. }
  37. function Get-CampaignIdentity {
  38. param($Context)
  39. $settings = $Context.Settings
  40. foreach ($key in @('SMTP_FROM','SMTP_REPLY_TO','SMTP_TEST_TO','BUSINESS_POSTAL_ADDRESS')) {
  41. if (-not $settings[$key] -or -not $settings[$key].Trim()) { throw "Set $key in config.ini first" }
  42. }
  43. $from = $settings['SMTP_FROM'].Trim()
  44. $replyTo = $settings['SMTP_REPLY_TO'].Trim()
  45. $testTo = $settings['SMTP_TEST_TO'].Trim()
  46. foreach ($address in @($from, $replyTo, $testTo)) {
  47. $parsed = New-Object System.Net.Mail.MailAddress($address)
  48. if ($parsed.Address -ne $address) { throw 'SMTP addresses must each be a single bare email address' }
  49. }
  50. $postal = $settings['BUSINESS_POSTAL_ADDRESS'].Trim()
  51. if ($postal.Length -gt 500 -or $postal -match '[\x00-\x1f]') {
  52. throw 'BUSINESS_POSTAL_ADDRESS must be one line of at most 500 characters'
  53. }
  54. return @{ From = $from; ReplyTo = $replyTo; TestTo = $testTo; Postal = $postal }
  55. }
  56. function New-DbCommand {
  57. param($Connection, [string]$Sql, $Transaction = $null)
  58. $command = $Connection.CreateCommand()
  59. $command.CommandText = $Sql
  60. if ($Transaction) { $command.Transaction = $Transaction }
  61. return $command
  62. }
  63. function Get-EligibleContacts {
  64. param($Connection, $Transaction = $null, $ContactIds = $null)
  65. $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
  66. $reader = $command.ExecuteReader()
  67. $contacts = New-Object System.Collections.ArrayList
  68. try {
  69. while ($reader.Read()) {
  70. $null = $contacts.Add(@{ Id = [int]$reader.GetValue(0);
  71. Email = [string]$reader.GetValue(1); Key = [string]$reader.GetValue(2) })
  72. }
  73. }
  74. finally { $reader.Dispose() }
  75. if ($null -ne $ContactIds) {
  76. $eligible = @{}
  77. foreach ($contact in $contacts) { $eligible[$contact.Id] = $contact }
  78. $selected = New-Object System.Collections.ArrayList
  79. foreach ($contactId in $ContactIds) {
  80. if (-not $eligible.ContainsKey([int]$contactId)) {
  81. throw 'A selected contact is no longer eligible or was suppressed. Review again.'
  82. }
  83. $null = $selected.Add($eligible[[int]$contactId])
  84. }
  85. $contacts = $selected
  86. }
  87. if ($contacts.Count -lt 1) { throw 'No eligible, unsuppressed US contacts are available' }
  88. if ($contacts.Count -gt 500) { throw 'This version supports at most 500 eligible contacts per campaign' }
  89. return ,$contacts
  90. }
  91. function Get-TemplateSnapshot {
  92. param($Connection, [int]$TemplateId, $Transaction = $null)
  93. $command = New-DbCommand $Connection 'SELECT [TemplateName],[VersionNumber],[HtmlContent],[ContentHash],[ValidationStatus] FROM [TemplateVersion] WHERE [Id]=?' $Transaction
  94. $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
  95. $command.Parameters[0].Value = $TemplateId
  96. $reader = $command.ExecuteReader()
  97. try {
  98. if (-not $reader.Read()) { throw 'Selected template version was not found' }
  99. $name = [string]$reader.GetValue(0)
  100. $version = [int]$reader.GetValue(1)
  101. $html = [string]$reader.GetValue(2)
  102. $hash = [string]$reader.GetValue(3)
  103. if ([string]$reader.GetValue(4) -ne 'validated' -or
  104. (Get-HashHex ([System.Text.Encoding]::UTF8.GetBytes($html))) -ne $hash) {
  105. throw 'Template validation or content hash failed'
  106. }
  107. }
  108. finally { $reader.Dispose() }
  109. $document = Read-SafeTemplateXml $html $true
  110. $images = New-Object System.Collections.ArrayList
  111. $command = New-DbCommand $Connection 'SELECT [ContentType],[ContentHash],[SnapshotBytes],[ContentId] FROM [TemplateImage] WHERE [TemplateVersionId]=? ORDER BY [Id]' $Transaction
  112. $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
  113. $command.Parameters[0].Value = $TemplateId
  114. $reader = $command.ExecuteReader()
  115. try {
  116. while ($reader.Read()) {
  117. $bytes = [byte[]]$reader.GetValue(2)
  118. $cid = [string]$reader.GetValue(3)
  119. $type = [string]$reader.GetValue(0)
  120. if ((Get-HashHex $bytes) -ne [string]$reader.GetValue(1) -or
  121. $cid -notmatch '^mm-[0-9a-f]{32}@local$' -or
  122. $type -notin @('image/png','image/jpeg','image/gif')) {
  123. throw 'Stored template image failed validation'
  124. }
  125. $null = $images.Add(@{ Bytes = $bytes; Cid = $cid; Type = $type })
  126. }
  127. }
  128. finally { $reader.Dispose() }
  129. $references = @{}
  130. foreach ($image in $images) { $references['cid:' + $image.Cid] = $false }
  131. foreach ($node in $document.GetElementsByTagName('img')) {
  132. $src = $node.GetAttribute('src')
  133. if (-not $references.ContainsKey($src)) { throw 'Template image reference has no stored snapshot' }
  134. $references[$src] = $true
  135. }
  136. foreach ($entry in $references.GetEnumerator()) {
  137. if (-not $entry.Value) { throw 'Stored template image has no HTML reference' }
  138. }
  139. return @{ Name = $name; Version = $version; Html = $html; Hash = $hash;
  140. Document = $document; Images = $images }
  141. }
  142. function Add-CampaignFooter {
  143. param($Document, $Identity)
  144. $body = @($Document.DocumentElement.ChildNodes | Where-Object { $_.LocalName -eq 'body' })[0]
  145. if (-not $body) { throw 'Stored template is missing <body>' }
  146. $ns = $Document.DocumentElement.NamespaceURI
  147. $divider = $Document.CreateElement('hr', $ns)
  148. $null = $body.AppendChild($divider)
  149. $footer = $Document.CreateElement('div', $ns)
  150. $footer.SetAttribute('style', 'font-size:12px; color:#444444')
  151. foreach ($line in @(
  152. 'Advertisement from ' + $Identity.From + '.',
  153. $Identity.Postal,
  154. 'To stop receiving marketing emails, reply to ' + $Identity.ReplyTo + ' with UNSUBSCRIBE in the subject.'
  155. )) {
  156. $paragraph = $Document.CreateElement('p', $ns)
  157. $null = $paragraph.AppendChild($Document.CreateTextNode($line))
  158. $null = $footer.AppendChild($paragraph)
  159. }
  160. $null = $body.AppendChild($footer)
  161. return $Document.OuterXml
  162. }
  163. function Get-CampaignReview {
  164. param($Context, $Request, $Connection, $Transaction = $null)
  165. $identity = Get-CampaignIdentity $Context
  166. $template = Get-TemplateSnapshot $Connection $Request.TemplateId $Transaction
  167. $contacts = Get-EligibleContacts $Connection $Transaction $Request.ContactIds
  168. $html = Add-CampaignFooter $template.Document $identity
  169. $parts = New-Object System.Collections.ArrayList
  170. foreach ($value in @($Request.Name, $Request.Subject, [string]$Request.TemplateId,
  171. $template.Hash, $identity.From, $identity.ReplyTo, $identity.Postal)) {
  172. $null = $parts.Add($value)
  173. }
  174. foreach ($contact in $contacts) { $null = $parts.Add(([string]$contact.Id + ':' + $contact.Key)) }
  175. $token = Get-HashHex ([System.Text.Encoding]::UTF8.GetBytes(($parts -join [char]0)))
  176. return @{ Name = $Request.Name; Subject = $Request.Subject; TemplateId = $Request.TemplateId;
  177. TemplateName = $template.Name; TemplateVersion = $template.Version; Identity = $identity;
  178. Contacts = $contacts; Html = $html; Images = $template.Images; Token = $token }
  179. }
  180. function Assert-ReviewToken {
  181. param($Request, $Review)
  182. if ($Request.ReviewToken -notmatch '^[0-9a-f]{64}$' -or $Request.ReviewToken -ne $Review.Token) {
  183. throw 'Campaign inputs, recipients, template, or settings changed. Review again.'
  184. }
  185. }
  186. function Write-CampaignPreview {
  187. param($Review, [string]$OutputRoot)
  188. $folder = Join-Path $OutputRoot ([guid]::NewGuid().ToString('N'))
  189. $null = New-Item -ItemType Directory -Path $folder -Force
  190. $document = Read-SafeTemplateXml $Review.Html $true
  191. $index = 0
  192. foreach ($image in $Review.Images) {
  193. $index++
  194. $extension = switch ($image.Type) { 'image/png' { 'png' } 'image/jpeg' { 'jpg' } 'image/gif' { 'gif' } }
  195. $filename = "image-$index.$extension"
  196. [System.IO.File]::WriteAllBytes((Join-Path $folder $filename), $image.Bytes)
  197. foreach ($node in $document.GetElementsByTagName('img')) {
  198. if ($node.GetAttribute('src') -eq ('cid:' + $image.Cid)) { $node.SetAttribute('src', $filename) }
  199. }
  200. }
  201. $file = Join-Path $folder 'preview.html'
  202. [System.IO.File]::WriteAllText($file, $document.OuterXml, (New-Object System.Text.UTF8Encoding($false)))
  203. return $file
  204. }
  205. function Write-CampaignAudit {
  206. param($Connection, [string]$Action, [string]$Token, [string]$Detail, $Transaction = $null)
  207. $command = New-DbCommand $Connection 'INSERT INTO [AuditEvent] ([Actor],[Action],[EntityId],[TimestampUtc],[Detail]) VALUES (?,?,?,?,?)' $Transaction
  208. Add-TextParameter $command 'actor' 100 'local_operator'
  209. Add-TextParameter $command 'action' 100 $Action
  210. Add-TextParameter $command 'entity' 100 $Token
  211. $null = $command.Parameters.Add('at', [System.Data.OleDb.OleDbType]::Date)
  212. $command.Parameters[3].Value = [DateTime]::UtcNow
  213. Add-TextParameter $command 'detail' 255 $Detail
  214. $null = $command.ExecuteNonQuery()
  215. }

Powered by TurnKey Linux.