$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() }