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'))." if ($Send) { $sentNote = Save-SentCopySafely $settings $password $message; if ($sentNote) { Write-Output $sentNote } } } 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() }