You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

192 line
8.9KB

  1. $ErrorActionPreference = 'Stop'
  2. . (Join-Path $PSScriptRoot 'campaign-common.ps1')
  3. function Enter-DispatchLock {
  4. param([int]$WaitMilliseconds = 0)
  5. $folder = Join-Path (Split-Path -Parent $PSScriptRoot) 'temp'
  6. if (-not (Test-Path -LiteralPath $folder -PathType Container)) {
  7. $null = New-Item -ItemType Directory -Path $folder -Force
  8. }
  9. $path = Join-Path $folder 'dispatch.lock'
  10. $deadline = [DateTime]::UtcNow.AddMilliseconds($WaitMilliseconds)
  11. while ($true) {
  12. try {
  13. return [System.IO.File]::Open($path, [System.IO.FileMode]::OpenOrCreate,
  14. [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
  15. }
  16. catch [System.IO.IOException] {
  17. if ([DateTime]::UtcNow -ge $deadline) {
  18. throw 'Another dispatch or campaign control action is still running'
  19. }
  20. Start-Sleep -Milliseconds 250
  21. }
  22. }
  23. }
  24. function Get-QueueInterval {
  25. param($Context)
  26. $interval = 0
  27. if (-not [int]::TryParse($Context.Settings['QUEUE_INTERVAL'], [ref]$interval) -or
  28. $interval -lt 60 -or $interval -gt 86400) {
  29. throw 'QUEUE_INTERVAL must be 60-86400 seconds'
  30. }
  31. return $interval
  32. }
  33. function Get-ReviewHours {
  34. param($Context)
  35. $hours = 0
  36. if (-not [int]::TryParse($Context.Settings['OPT_OUT_REVIEW_HOURS'], [ref]$hours) -or
  37. $hours -lt 1 -or $hours -gt 168) {
  38. throw 'OPT_OUT_REVIEW_HOURS must be 1-168'
  39. }
  40. return $hours
  41. }
  42. function Get-OperationalState {
  43. param($Connection, $Transaction = $null)
  44. $command = New-DbCommand $Connection 'SELECT [GlobalPause],[LastOptOutReviewAtUtc] FROM [OperationalState] WHERE [Id]=1' $Transaction
  45. $reader = $command.ExecuteReader()
  46. try {
  47. if (-not $reader.Read()) { throw 'OperationalState row is missing' }
  48. $reviewed = if ($reader.IsDBNull(1)) { $null } else { [DateTime]$reader.GetValue(1) }
  49. return @{ Paused = [bool]$reader.GetValue(0); Reviewed = $reviewed }
  50. }
  51. finally { $reader.Dispose() }
  52. }
  53. function Assert-OptOutReviewCurrent {
  54. param($Context, $State)
  55. $hours = Get-ReviewHours $Context
  56. if ($null -eq $State.Reviewed -or $State.Reviewed -lt [DateTime]::UtcNow.AddHours(-$hours)) {
  57. throw 'Review the reply mailbox and record opt-outs before sending'
  58. }
  59. }
  60. function Get-DispatchClock {
  61. param($Connection, [string]$SenderKey, [int]$Interval, $Transaction = $null)
  62. $command = New-DbCommand $Connection 'SELECT [LastAttemptAtUtc],[NextAllowedAttemptUtc] FROM [DispatchState] WHERE [SenderKey]=?' $Transaction
  63. Add-TextParameter $command 'sender' 254 $SenderKey
  64. $reader = $command.ExecuteReader()
  65. try {
  66. if (-not $reader.Read()) { return @{ Exists = $false; Last = $null; Next = $null } }
  67. $last = if ($reader.IsDBNull(0)) { $null } else { [DateTime]$reader.GetValue(0) }
  68. $next = if ($reader.IsDBNull(1)) { $null } else { [DateTime]$reader.GetValue(1) }
  69. }
  70. finally { $reader.Dispose() }
  71. if ($null -ne $last) {
  72. $newGap = $last.AddSeconds($Interval)
  73. if ($null -eq $next -or $newGap -gt $next) { $next = $newGap }
  74. }
  75. return @{ Exists = $true; Last = $last; Next = $next }
  76. }
  77. function Assert-OpenHtaSession {
  78. param([int]$HtaPid, [string]$Token)
  79. if ($HtaPid -lt 1 -or $Token -notmatch '^[0-9a-f]{32}$') { throw 'Missing HTA session token' }
  80. $path = Join-Path (Split-Path -Parent $PSScriptRoot) "temp\session-$HtaPid.txt"
  81. if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'HTA session is closed' }
  82. $file = Get-Item -LiteralPath $path
  83. if ($file.LastWriteTimeUtc -lt [DateTime]::UtcNow.AddSeconds(-20) -or
  84. ([System.IO.File]::ReadAllText($path)).Trim() -ne $Token) {
  85. throw 'HTA session heartbeat is stale'
  86. }
  87. $process = Get-WmiObject Win32_Process -Filter "ProcessId=$HtaPid" -ErrorAction Stop
  88. if (-not $process -or $process.Name -ne 'mshta.exe' -or
  89. $process.CommandLine -notmatch '(?i)MassMailer\.hta') {
  90. throw 'HTA process is not running'
  91. }
  92. }
  93. function Get-DeliverySnapshot {
  94. param($Connection, [int]$DeliveryId)
  95. $sql = 'SELECT d.[EmailKey],c.[EmailOriginal],m.[FromSnapshot],m.[ReplyToSnapshot],m.[PostalAddressSnapshot],m.[Subject],m.[TemplateVersionId],m.[Status] FROM ([Delivery] AS d INNER JOIN [Contact] AS c ON d.[ContactId]=c.[Id]) INNER JOIN [Campaign] AS m ON d.[CampaignId]=m.[Id] WHERE d.[Id]=?'
  96. $command = New-DbCommand $Connection $sql
  97. $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
  98. $command.Parameters[0].Value = $DeliveryId
  99. $reader = $command.ExecuteReader()
  100. try {
  101. if (-not $reader.Read()) { throw 'Reserved delivery is missing' }
  102. $emailKey = [string]$reader.GetValue(0)
  103. $address = [string]$reader.GetValue(1)
  104. if ($emailKey -ne $address.ToLowerInvariant()) { throw 'Contact email changed after approval' }
  105. return @{ EmailKey = $emailKey; Email = $address; From = [string]$reader.GetValue(2);
  106. ReplyTo = [string]$reader.GetValue(3); Postal = [string]$reader.GetValue(4);
  107. Subject = [string]$reader.GetValue(5); TemplateId = [int]$reader.GetValue(6);
  108. CampaignStatus = [string]$reader.GetValue(7) }
  109. }
  110. finally { $reader.Dispose() }
  111. }
  112. function Get-ApprovedCampaignSnapshot {
  113. param($Connection, [int]$CampaignId)
  114. $command = New-DbCommand $Connection 'SELECT [FromSnapshot],[ReplyToSnapshot],[PostalAddressSnapshot],[Subject],[TemplateVersionId],[Status] FROM [Campaign] WHERE [Id]=?'
  115. $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
  116. $command.Parameters[0].Value = $CampaignId
  117. $reader = $command.ExecuteReader()
  118. try {
  119. if (-not $reader.Read()) { throw 'Approved campaign was not found' }
  120. $status = [string]$reader.GetValue(5)
  121. if ($status -notin @('approved_paused','running')) {
  122. throw 'Campaign is not approved and active'
  123. }
  124. return @{ From = [string]$reader.GetValue(0); ReplyTo = [string]$reader.GetValue(1);
  125. Postal = [string]$reader.GetValue(2); Subject = [string]$reader.GetValue(3);
  126. TemplateId = [int]$reader.GetValue(4) }
  127. }
  128. finally { $reader.Dispose() }
  129. }
  130. function Test-Suppressed {
  131. param($Connection, [string]$EmailKey, $Transaction = $null)
  132. $command = New-DbCommand $Connection 'SELECT COUNT(*) FROM [Suppression] WHERE [EmailKey]=?' $Transaction
  133. Add-TextParameter $command 'key' 254 $EmailKey
  134. return [int]$command.ExecuteScalar() -gt 0
  135. }
  136. function Complete-DeliveryAttempt {
  137. param($Connection, [int]$DeliveryId, [int]$AttemptId,
  138. [string]$Outcome, [string]$Status, [string]$ErrorCode = '')
  139. $transaction = $Connection.BeginTransaction()
  140. try {
  141. $attempt = New-DbCommand $Connection 'UPDATE [Attempt] SET [EndedAtUtc]=?,[Outcome]=?,[SanitizedError]=? WHERE [Id]=? AND [Outcome]=?' $transaction
  142. $null = $attempt.Parameters.Add('ended', [System.Data.OleDb.OleDbType]::Date)
  143. $attempt.Parameters[0].Value = [DateTime]::UtcNow
  144. Add-TextParameter $attempt 'outcome' 30 $Outcome
  145. Add-TextParameter $attempt 'error' 255 $ErrorCode
  146. $null = $attempt.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
  147. $attempt.Parameters[3].Value = $AttemptId
  148. Add-TextParameter $attempt 'started' 30 'started'
  149. if ($attempt.ExecuteNonQuery() -ne 1) { throw 'Attempt was already finalized' }
  150. $delivery = New-DbCommand $Connection 'UPDATE [Delivery] SET [Status]=?,[LastErrorCode]=? WHERE [Id]=? AND [Status]=?' $transaction
  151. Add-TextParameter $delivery 'status' 30 $Status
  152. Add-TextParameter $delivery 'error' 100 $ErrorCode
  153. $null = $delivery.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
  154. $delivery.Parameters[2].Value = $DeliveryId
  155. Add-TextParameter $delivery 'inflight' 30 'in_flight'
  156. if ($delivery.ExecuteNonQuery() -ne 1) { throw 'Delivery was already finalized' }
  157. $transaction.Commit()
  158. }
  159. catch { $transaction.Rollback(); throw }
  160. }
  161. function Pause-DispatchForReview {
  162. param($Connection, [int]$DeliveryId)
  163. $transaction = $Connection.BeginTransaction()
  164. try {
  165. $command = New-DbCommand $Connection "UPDATE [Campaign] SET [Status]='approved_paused' WHERE [Status]='running'" $transaction
  166. $null = $command.ExecuteNonQuery()
  167. $command = New-DbCommand $Connection 'UPDATE [OperationalState] SET [GlobalPause]=TRUE WHERE [Id]=1' $transaction
  168. $null = $command.ExecuteNonQuery()
  169. Write-CampaignAudit $Connection 'dispatch_uncertain' ([string]$DeliveryId) 'Provider outcome requires manual review' $transaction
  170. $transaction.Commit()
  171. }
  172. catch { $transaction.Rollback(); throw }
  173. }
  174. function Complete-FinishedCampaigns {
  175. param($Connection)
  176. $command = New-DbCommand $Connection "UPDATE [Campaign] SET [Status]='completed' WHERE [Status]='running' AND [Id] NOT IN (SELECT [CampaignId] FROM [Delivery] WHERE [Status] IN ('pending','in_flight','uncertain'))"
  177. $null = $command.ExecuteNonQuery()
  178. }

Powered by TurnKey Linux.