|
- $ErrorActionPreference = 'Stop'
- . (Join-Path $PSScriptRoot 'campaign-common.ps1')
-
- function Enter-DispatchLock {
- param([int]$WaitMilliseconds = 0)
- $folder = Join-Path (Split-Path -Parent $PSScriptRoot) 'temp'
- if (-not (Test-Path -LiteralPath $folder -PathType Container)) {
- $null = New-Item -ItemType Directory -Path $folder -Force
- }
- $path = Join-Path $folder 'dispatch.lock'
- $deadline = [DateTime]::UtcNow.AddMilliseconds($WaitMilliseconds)
- while ($true) {
- try {
- return [System.IO.File]::Open($path, [System.IO.FileMode]::OpenOrCreate,
- [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
- }
- catch [System.IO.IOException] {
- if ([DateTime]::UtcNow -ge $deadline) {
- throw 'Another dispatch or campaign control action is still running'
- }
- Start-Sleep -Milliseconds 250
- }
- }
- }
-
- function Get-QueueInterval {
- param($Context)
- $interval = 0
- if (-not [int]::TryParse($Context.Settings['QUEUE_INTERVAL'], [ref]$interval) -or
- $interval -lt 60 -or $interval -gt 86400) {
- throw 'QUEUE_INTERVAL must be 60-86400 seconds'
- }
- return $interval
- }
-
- function Get-ReviewHours {
- param($Context)
- $hours = 0
- if (-not [int]::TryParse($Context.Settings['OPT_OUT_REVIEW_HOURS'], [ref]$hours) -or
- $hours -lt 1 -or $hours -gt 168) {
- throw 'OPT_OUT_REVIEW_HOURS must be 1-168'
- }
- return $hours
- }
-
- function Get-OperationalState {
- param($Connection, $Transaction = $null)
- $command = New-DbCommand $Connection 'SELECT [GlobalPause],[LastOptOutReviewAtUtc] FROM [OperationalState] WHERE [Id]=1' $Transaction
- $reader = $command.ExecuteReader()
- try {
- if (-not $reader.Read()) { throw 'OperationalState row is missing' }
- $reviewed = if ($reader.IsDBNull(1)) { $null } else { [DateTime]$reader.GetValue(1) }
- return @{ Paused = [bool]$reader.GetValue(0); Reviewed = $reviewed }
- }
- finally { $reader.Dispose() }
- }
-
- function Assert-OptOutReviewCurrent {
- param($Context, $State)
- $hours = Get-ReviewHours $Context
- if ($null -eq $State.Reviewed -or $State.Reviewed -lt [DateTime]::UtcNow.AddHours(-$hours)) {
- throw 'Review the reply mailbox and record opt-outs before sending'
- }
- }
-
- function Get-DispatchClock {
- param($Connection, [string]$SenderKey, [int]$Interval, $Transaction = $null)
- $command = New-DbCommand $Connection 'SELECT [LastAttemptAtUtc],[NextAllowedAttemptUtc] FROM [DispatchState] WHERE [SenderKey]=?' $Transaction
- Add-TextParameter $command 'sender' 254 $SenderKey
- $reader = $command.ExecuteReader()
- try {
- if (-not $reader.Read()) { return @{ Exists = $false; Last = $null; Next = $null } }
- $last = if ($reader.IsDBNull(0)) { $null } else { [DateTime]$reader.GetValue(0) }
- $next = if ($reader.IsDBNull(1)) { $null } else { [DateTime]$reader.GetValue(1) }
- }
- finally { $reader.Dispose() }
- if ($null -ne $last) {
- $newGap = $last.AddSeconds($Interval)
- if ($null -eq $next -or $newGap -gt $next) { $next = $newGap }
- }
- return @{ Exists = $true; Last = $last; Next = $next }
- }
-
- function Assert-OpenHtaSession {
- param([int]$HtaPid, [string]$Token)
- if ($HtaPid -lt 1 -or $Token -notmatch '^[0-9a-f]{32}$') { throw 'Missing HTA session token' }
- $path = Join-Path (Split-Path -Parent $PSScriptRoot) "temp\session-$HtaPid.txt"
- if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw 'HTA session is closed' }
- $file = Get-Item -LiteralPath $path
- if ($file.LastWriteTimeUtc -lt [DateTime]::UtcNow.AddSeconds(-20) -or
- ([System.IO.File]::ReadAllText($path)).Trim() -ne $Token) {
- throw 'HTA session heartbeat is stale'
- }
- $process = Get-WmiObject Win32_Process -Filter "ProcessId=$HtaPid" -ErrorAction Stop
- if (-not $process -or $process.Name -ne 'mshta.exe' -or
- $process.CommandLine -notmatch '(?i)MassMailer\.hta') {
- throw 'HTA process is not running'
- }
- }
-
- function Get-DeliverySnapshot {
- param($Connection, [int]$DeliveryId)
- $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]=?'
- $command = New-DbCommand $Connection $sql
- $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
- $command.Parameters[0].Value = $DeliveryId
- $reader = $command.ExecuteReader()
- try {
- if (-not $reader.Read()) { throw 'Reserved delivery is missing' }
- $emailKey = [string]$reader.GetValue(0)
- $address = [string]$reader.GetValue(1)
- if ($emailKey -ne $address.ToLowerInvariant()) { throw 'Contact email changed after approval' }
- return @{ EmailKey = $emailKey; Email = $address; From = [string]$reader.GetValue(2);
- ReplyTo = [string]$reader.GetValue(3); Postal = [string]$reader.GetValue(4);
- Subject = [string]$reader.GetValue(5); TemplateId = [int]$reader.GetValue(6);
- CampaignStatus = [string]$reader.GetValue(7) }
- }
- finally { $reader.Dispose() }
- }
-
- function Get-ApprovedCampaignSnapshot {
- param($Connection, [int]$CampaignId)
- $command = New-DbCommand $Connection 'SELECT [FromSnapshot],[ReplyToSnapshot],[PostalAddressSnapshot],[Subject],[TemplateVersionId],[Status] FROM [Campaign] WHERE [Id]=?'
- $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
- $command.Parameters[0].Value = $CampaignId
- $reader = $command.ExecuteReader()
- try {
- if (-not $reader.Read()) { throw 'Approved campaign was not found' }
- $status = [string]$reader.GetValue(5)
- if ($status -notin @('approved_paused','running')) {
- throw 'Campaign is not approved and active'
- }
- return @{ From = [string]$reader.GetValue(0); ReplyTo = [string]$reader.GetValue(1);
- Postal = [string]$reader.GetValue(2); Subject = [string]$reader.GetValue(3);
- TemplateId = [int]$reader.GetValue(4) }
- }
- finally { $reader.Dispose() }
- }
-
- function Test-Suppressed {
- param($Connection, [string]$EmailKey, $Transaction = $null)
- $command = New-DbCommand $Connection 'SELECT COUNT(*) FROM [Suppression] WHERE [EmailKey]=?' $Transaction
- Add-TextParameter $command 'key' 254 $EmailKey
- return [int]$command.ExecuteScalar() -gt 0
- }
-
- function Complete-DeliveryAttempt {
- param($Connection, [int]$DeliveryId, [int]$AttemptId,
- [string]$Outcome, [string]$Status, [string]$ErrorCode = '')
- $transaction = $Connection.BeginTransaction()
- try {
- $attempt = New-DbCommand $Connection 'UPDATE [Attempt] SET [EndedAtUtc]=?,[Outcome]=?,[SanitizedError]=? WHERE [Id]=? AND [Outcome]=?' $transaction
- $null = $attempt.Parameters.Add('ended', [System.Data.OleDb.OleDbType]::Date)
- $attempt.Parameters[0].Value = [DateTime]::UtcNow
- Add-TextParameter $attempt 'outcome' 30 $Outcome
- Add-TextParameter $attempt 'error' 255 $ErrorCode
- $null = $attempt.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
- $attempt.Parameters[3].Value = $AttemptId
- Add-TextParameter $attempt 'started' 30 'started'
- if ($attempt.ExecuteNonQuery() -ne 1) { throw 'Attempt was already finalized' }
- $delivery = New-DbCommand $Connection 'UPDATE [Delivery] SET [Status]=?,[LastErrorCode]=? WHERE [Id]=? AND [Status]=?' $transaction
- Add-TextParameter $delivery 'status' 30 $Status
- Add-TextParameter $delivery 'error' 100 $ErrorCode
- $null = $delivery.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
- $delivery.Parameters[2].Value = $DeliveryId
- Add-TextParameter $delivery 'inflight' 30 'in_flight'
- if ($delivery.ExecuteNonQuery() -ne 1) { throw 'Delivery was already finalized' }
- $transaction.Commit()
- }
- catch { $transaction.Rollback(); throw }
- }
-
- function Pause-DispatchForReview {
- param($Connection, [int]$DeliveryId)
- $transaction = $Connection.BeginTransaction()
- try {
- $command = New-DbCommand $Connection "UPDATE [Campaign] SET [Status]='approved_paused' WHERE [Status]='running'" $transaction
- $null = $command.ExecuteNonQuery()
- $command = New-DbCommand $Connection 'UPDATE [OperationalState] SET [GlobalPause]=TRUE WHERE [Id]=1' $transaction
- $null = $command.ExecuteNonQuery()
- Write-CampaignAudit $Connection 'dispatch_uncertain' ([string]$DeliveryId) 'Provider outcome requires manual review' $transaction
- $transaction.Commit()
- }
- catch { $transaction.Rollback(); throw }
- }
-
- function Complete-FinishedCampaigns {
- param($Connection)
- $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'))"
- $null = $command.ExecuteNonQuery()
- }
|