25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

200 lines
9.7KB

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

Powered by TurnKey Linux.