|
- param(
- [string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'),
- [switch]$Send,
- [string]$PickupDirectory,
- [int]$SessionPid,
- [string]$SessionToken,
- [string]$TestNowUtc,
- [switch]$TestSimulateUnknownAfterSend
- )
- $ErrorActionPreference = 'Stop'
- . (Join-Path $PSScriptRoot 'dispatch-common.ps1')
- if ([bool]$Send -eq [bool]$PickupDirectory) { throw 'Choose exactly one mode: -Send or -PickupDirectory' }
- if ($Send -and $TestNowUtc) { throw 'TestNowUtc is only available in pickup mode' }
- if ($Send -and $TestSimulateUnknownAfterSend) { throw 'Failure injection is only available in pickup mode' }
- if ($Send) { Assert-OpenHtaSession $SessionPid $SessionToken }
- elseif (-not (Test-Path -LiteralPath $PickupDirectory -PathType Container)) {
- throw 'Pickup directory does not exist'
- }
- $now = if ($TestNowUtc) {
- [DateTime]::Parse($TestNowUtc, [cultureinfo]::InvariantCulture,
- [System.Globalization.DateTimeStyles]::AdjustToUniversal)
- } else { [DateTime]::UtcNow }
- $context = Get-MailContext $ConfigPath
- $settings = $context.Settings
- $interval = Get-QueueInterval $context
- if ($settings['SMTP_TLS'] -ne 'on' -or $settings['SMTP_STARTTLS'] -ne 'on') {
- throw 'SMTP_TLS and SMTP_STARTTLS must both be on'
- }
- $port = 0
- if (-not [int]::TryParse($settings['SMTP_PORT'], [ref]$port) -or $port -lt 1 -or $port -gt 65535) {
- throw 'Invalid SMTP_PORT'
- }
- $sender = $settings['SMTP_USER'].ToLowerInvariant()
- $lock = Enter-DispatchLock
- $db = $null
- $password = $null
- $client = $null
- $message = $null
- $reservedDelivery = 0
- $reservedAttempt = 0
- $sendStarted = $false
- try {
- $db = Open-MailDatabase $context
- $state = Get-OperationalState $db
- if ($state.Paused) { Write-Output 'Dispatch paused'; return }
- Assert-OptOutReviewCurrent $context $state
- $clock = Get-DispatchClock $db $sender $interval
- if ($null -ne $clock.Next -and $now -lt $clock.Next) {
- Write-Output ('Next attempt allowed at ' + $clock.Next.ToString('o'))
- return
- }
- if ($Send) { $password = Load-SmtpPassword $context }
- $transaction = $db.BeginTransaction()
- try {
- while ($true) {
- $sql = "SELECT TOP 1 d.[Id],d.[EmailKey] FROM [Delivery] AS d INNER JOIN [Campaign] AS m ON d.[CampaignId]=m.[Id] WHERE d.[Status]='pending' AND m.[Status]='running' AND m.[StartAtUtc]<=? ORDER BY d.[Id]"
- $pick = New-DbCommand $db $sql $transaction
- $null = $pick.Parameters.Add('now', [System.Data.OleDb.OleDbType]::Date)
- $pick.Parameters[0].Value = $now
- $reader = $pick.ExecuteReader()
- try {
- if (-not $reader.Read()) { $candidate = $null }
- else { $candidate = @{ Id = [int]$reader.GetValue(0); Key = [string]$reader.GetValue(1) } }
- }
- finally { $reader.Dispose() }
- if ($null -eq $candidate) { break }
- if (-not (Test-Suppressed $db $candidate.Key $transaction)) { break }
- $skip = New-DbCommand $db "UPDATE [Delivery] SET [Status]='suppressed' WHERE [Id]=? AND [Status]='pending'" $transaction
- $null = $skip.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
- $skip.Parameters[0].Value = $candidate.Id
- $null = $skip.ExecuteNonQuery()
- }
- if ($null -eq $candidate) {
- $transaction.Commit()
- Complete-FinishedCampaigns $db
- Write-Output 'No pending delivery is due'
- return
- }
- $delivery = New-DbCommand $db "UPDATE [Delivery] SET [Status]='in_flight',[AttemptCount]=[AttemptCount]+1,[LastAttemptAtUtc]=? WHERE [Id]=? AND [Status]='pending'" $transaction
- $null = $delivery.Parameters.Add('now', [System.Data.OleDb.OleDbType]::Date)
- $delivery.Parameters[0].Value = $now
- $null = $delivery.Parameters.Add('id', [System.Data.OleDb.OleDbType]::Integer)
- $delivery.Parameters[1].Value = $candidate.Id
- if ($delivery.ExecuteNonQuery() -ne 1) { throw 'Delivery was already reserved' }
- $attempt = New-DbCommand $db 'INSERT INTO [Attempt] ([DeliveryId],[StartedAtUtc],[Outcome]) VALUES (?,?,?)' $transaction
- $null = $attempt.Parameters.Add('delivery', [System.Data.OleDb.OleDbType]::Integer)
- $attempt.Parameters[0].Value = $candidate.Id
- $null = $attempt.Parameters.Add('now', [System.Data.OleDb.OleDbType]::Date)
- $attempt.Parameters[1].Value = $now
- Add-TextParameter $attempt 'outcome' 30 'started'
- $null = $attempt.ExecuteNonQuery()
- $identity = New-DbCommand $db 'SELECT @@IDENTITY' $transaction
- $reservedAttempt = [int]$identity.ExecuteScalar()
- $next = $now.AddSeconds($interval)
- if ($clock.Exists) {
- $pace = New-DbCommand $db 'UPDATE [DispatchState] SET [LastAttemptAtUtc]=?,[NextAllowedAttemptUtc]=? WHERE [SenderKey]=?' $transaction
- $null = $pace.Parameters.Add('last', [System.Data.OleDb.OleDbType]::Date)
- $pace.Parameters[0].Value = $now
- $null = $pace.Parameters.Add('next', [System.Data.OleDb.OleDbType]::Date)
- $pace.Parameters[1].Value = $next
- Add-TextParameter $pace 'sender' 254 $sender
- }
- else {
- $pace = New-DbCommand $db 'INSERT INTO [DispatchState] ([SenderKey],[LastAttemptAtUtc],[NextAllowedAttemptUtc]) VALUES (?,?,?)' $transaction
- Add-TextParameter $pace 'sender' 254 $sender
- $null = $pace.Parameters.Add('last', [System.Data.OleDb.OleDbType]::Date)
- $pace.Parameters[1].Value = $now
- $null = $pace.Parameters.Add('next', [System.Data.OleDb.OleDbType]::Date)
- $pace.Parameters[2].Value = $next
- }
- $null = $pace.ExecuteNonQuery()
- $reservedDelivery = $candidate.Id
- $transaction.Commit()
- }
- catch { $transaction.Rollback(); throw }
-
- try {
- if ($Send) { Assert-OpenHtaSession $SessionPid $SessionToken }
- $snapshot = Get-DeliverySnapshot $db $reservedDelivery
- if ($snapshot.CampaignStatus -ne 'running') { throw 'Campaign was paused or canceled before send' }
- if (Test-Suppressed $db $snapshot.EmailKey) {
- Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'skipped' 'suppressed' 'suppressed_before_send'
- Write-Output "Delivery $reservedDelivery skipped because it is suppressed"
- return
- }
- $template = Get-TemplateSnapshot $db $snapshot.TemplateId
- $html = Add-CampaignFooter $template.Document @{
- From = $snapshot.From; ReplyTo = $snapshot.ReplyTo; Postal = $snapshot.Postal }
- $message = New-Object System.Net.Mail.MailMessage
- $message.From = New-Object System.Net.Mail.MailAddress($snapshot.From)
- $null = $message.ReplyToList.Add($snapshot.ReplyTo)
- $null = $message.To.Add($snapshot.Email)
- $message.Subject = $snapshot.Subject
- $message.SubjectEncoding = [System.Text.Encoding]::UTF8
- $view = [System.Net.Mail.AlternateView]::CreateAlternateViewFromString(
- $html, [System.Text.Encoding]::UTF8, 'text/html')
- foreach ($image in $template.Images) {
- $stream = New-Object System.IO.MemoryStream -ArgumentList (,([byte[]]$image.Bytes))
- $resource = New-Object System.Net.Mail.LinkedResource($stream, $image.Type)
- $resource.ContentId = $image.Cid
- $resource.TransferEncoding = [System.Net.Mime.TransferEncoding]::Base64
- $null = $view.LinkedResources.Add($resource)
- }
- $null = $message.AlternateViews.Add($view)
- $client = New-Object System.Net.Mail.SmtpClient($settings['SMTP_HOST'], $port)
- $client.Timeout = 20000
- if ($Send) {
- $client.UseDefaultCredentials = $false
- $client.Credentials = New-Object System.Net.NetworkCredential -ArgumentList @($settings['SMTP_USER'], $password)
- $client.EnableSsl = $true
- Assert-OpenHtaSession $SessionPid $SessionToken
- if ((Get-OperationalState $db).Paused -or (Test-Suppressed $db $snapshot.EmailKey)) {
- throw 'Campaign paused or recipient suppressed before SMTP call'
- }
- }
- else {
- $client.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::SpecifiedPickupDirectory
- $client.PickupDirectoryLocation = [System.IO.Path]::GetFullPath($PickupDirectory)
- }
- $sendStarted = $true
- $client.Send($message)
- if ($TestSimulateUnknownAfterSend) { throw 'Injected loss of provider acknowledgement' }
- Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'accepted' 'accepted'
- Complete-FinishedCampaigns $db
- Write-Output "SMTP accepted delivery $reservedDelivery. Next slot at $($next.ToString('o'))."
- }
- catch {
- $code = if ($_.Exception -is [System.Net.Mail.SmtpException]) {
- 'smtp_' + $_.Exception.StatusCode.ToString()
- } elseif ($sendStarted) { 'provider_outcome_unknown' } else { 'before_send_error' }
- if ($sendStarted) {
- Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'uncertain' 'uncertain' $code
- Pause-DispatchForReview $db $reservedDelivery
- Write-Output "Delivery $reservedDelivery has uncertain provider outcome ($code). All campaigns paused for manual review."
- }
- elseif ($_.Exception.Message -match 'HTA session|Campaign was paused') {
- Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'aborted' 'pending' 'session_closed_before_send'
- Write-Output "Delivery $reservedDelivery was not sent because the HTA session closed or paused. It remains pending."
- }
- else {
- Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'failed' 'failed' $code
- Write-Output "Delivery $reservedDelivery failed before SMTP ($code)."
- }
- }
- }
- finally {
- if ($client) { $client.Dispose() }
- if ($message) { $message.Dispose() }
- if ($password) { $password.Dispose() }
- if ($db) { $db.Dispose() }
- $lock.Dispose()
- }
|