Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>master
| @@ -10,6 +10,9 @@ SMTP_FROM=danielcovington@postcardmojo.com | |||||
| SMTP_REPLY_TO=danielcovington@postcardmojo.com | SMTP_REPLY_TO=danielcovington@postcardmojo.com | ||||
| SMTP_TEST_TO= | SMTP_TEST_TO= | ||||
| SMTP_SECRET_ID=primary_smtp | SMTP_SECRET_ID=primary_smtp | ||||
| IMAP_HOST= | |||||
| IMAP_PORT=993 | |||||
| IMAP_SENT_FOLDER=Sent | |||||
| DB_PATH=data/MassMailer.mdb | DB_PATH=data/MassMailer.mdb | ||||
| DB_PROVIDER=Microsoft.ACE.OLEDB.12.0 | DB_PROVIDER=Microsoft.ACE.OLEDB.12.0 | ||||
| DB_BITNESS=x86 | DB_BITNESS=x86 | ||||
| @@ -29,6 +32,9 @@ BACKUP_DIR=backups | |||||
| # QUEUE_INTERVAL is the minimum number of seconds between attempts across | # QUEUE_INTERVAL is the minimum number of seconds between attempts across | ||||
| # all campaigns using this SMTP account. TLS=on plus STARTTLS=on means the | # all campaigns using this SMTP account. TLS=on plus STARTTLS=on means the | ||||
| # sender must upgrade to TLS and fail closed if the server cannot do so. | # sender must upgrade to TLS and fail closed if the server cannot do so. | ||||
| # IMAP_HOST enables saving a copy of each live-sent message to the Sent folder | |||||
| # over implicit-TLS IMAP, using SMTP_USER and the stored SMTP password. Leave it | |||||
| # empty to disable. A failed save is reported but never affects delivery status. | |||||
| # SMTP_REPLY_TO is the monitored mailbox confirmed by the owner. | # SMTP_REPLY_TO is the monitored mailbox confirmed by the owner. | ||||
| # Set SMTP_TEST_TO to an owner-controlled address before the internal transport test. | # Set SMTP_TEST_TO to an owner-controlled address before the internal transport test. | ||||
| # DB_PROVIDER and DB_BITNESS are checked before the HTA opens. On this PC, | # DB_PROVIDER and DB_BITNESS are checked before the HTA opens. On this PC, | ||||
| @@ -63,6 +63,7 @@ Function IsAllowedKey(key) | |||||
| IsAllowedKey = False | IsAllowedKey = False | ||||
| For Each name In Array("SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_TLS", _ | For Each name In Array("SMTP_HOST", "SMTP_PORT", "SMTP_USER", "SMTP_TLS", _ | ||||
| "SMTP_STARTTLS", "SMTP_FROM", "SMTP_REPLY_TO", "SMTP_TEST_TO", "SMTP_SECRET_ID", _ | "SMTP_STARTTLS", "SMTP_FROM", "SMTP_REPLY_TO", "SMTP_TEST_TO", "SMTP_SECRET_ID", _ | ||||
| "IMAP_HOST", "IMAP_PORT", "IMAP_SENT_FOLDER", _ | |||||
| "DB_PATH", "DB_PROVIDER", "DB_BITNESS", "QUEUE_INTERVAL", _ | "DB_PATH", "DB_PROVIDER", "DB_BITNESS", "QUEUE_INTERVAL", _ | ||||
| "SEND_MODE", "DISPLAY_TIME_ZONE", "TEMPLATE_ROOT", _ | "SEND_MODE", "DISPLAY_TIME_ZONE", "TEMPLATE_ROOT", _ | ||||
| "TEMPLATE_IMAGE_MODE", "TEMPLATE_IMAGE_TYPES", _ | "TEMPLATE_IMAGE_MODE", "TEMPLATE_IMAGE_TYPES", _ | ||||
| @@ -163,6 +163,7 @@ try { | |||||
| Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'accepted' 'accepted' | Complete-DeliveryAttempt $db $reservedDelivery $reservedAttempt 'accepted' 'accepted' | ||||
| Complete-FinishedCampaigns $db | Complete-FinishedCampaigns $db | ||||
| Write-Output "SMTP accepted delivery $reservedDelivery. Next slot at $($next.ToString('o'))." | 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 { | catch { | ||||
| $code = if ($_.Exception -is [System.Net.Mail.SmtpException]) { | $code = if ($_.Exception -is [System.Net.Mail.SmtpException]) { | ||||
| @@ -109,3 +109,113 @@ function Load-SmtpPassword { | |||||
| $connection.Dispose() | $connection.Dispose() | ||||
| } | } | ||||
| } | } | ||||
| function Get-MessageMimeBytes { | |||||
| param([System.Net.Mail.MailMessage]$Message) | |||||
| # Rewind inline image streams that the SMTP send already consumed. | |||||
| foreach ($view in $Message.AlternateViews) { | |||||
| $view.ContentStream.Position = 0 | |||||
| foreach ($resource in $view.LinkedResources) { $resource.ContentStream.Position = 0 } | |||||
| } | |||||
| $directory = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString('N')) | |||||
| $null = New-Item -ItemType Directory -Path $directory | |||||
| $client = New-Object System.Net.Mail.SmtpClient | |||||
| try { | |||||
| $client.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::SpecifiedPickupDirectory | |||||
| $client.PickupDirectoryLocation = $directory | |||||
| $client.Send($Message) | |||||
| $file = Get-ChildItem -LiteralPath $directory -Filter '*.eml' | Select-Object -First 1 | |||||
| return [System.IO.File]::ReadAllBytes($file.FullName) | |||||
| } | |||||
| finally { | |||||
| $client.Dispose() | |||||
| Remove-Item -LiteralPath $directory -Recurse -Force -ErrorAction SilentlyContinue | |||||
| } | |||||
| } | |||||
| function Read-ImapLine { | |||||
| param([System.IO.Stream]$Stream) | |||||
| $bytes = New-Object System.Collections.Generic.List[byte] | |||||
| while ($true) { | |||||
| $value = $Stream.ReadByte() | |||||
| if ($value -lt 0) { throw 'IMAP connection closed' } | |||||
| if ($value -eq 10) { break } | |||||
| if ($value -ne 13) { $bytes.Add([byte]$value) } | |||||
| } | |||||
| return [System.Text.Encoding]::ASCII.GetString($bytes.ToArray()) | |||||
| } | |||||
| function Read-ImapResponse { | |||||
| param([System.IO.Stream]$Stream, [string]$Tag) | |||||
| while ($true) { | |||||
| $line = Read-ImapLine $Stream | |||||
| if ($line.StartsWith("$Tag ")) { | |||||
| if ($line -notmatch "^$Tag OK") { throw "IMAP command failed: $line" } | |||||
| return | |||||
| } | |||||
| } | |||||
| } | |||||
| function ConvertTo-ImapQuoted { | |||||
| param([string]$Value) | |||||
| if ($Value -match "[\r\n]") { throw 'Invalid IMAP value' } | |||||
| return '"' + $Value.Replace('\', '\').Replace('"', '\"') + '"' | |||||
| } | |||||
| # Appends a copy of an already-sent message to the Sent folder over IMAP. | |||||
| # Does nothing unless IMAP_HOST is configured. Callers treat failures as | |||||
| # non-fatal because the message itself has already been accepted by SMTP. | |||||
| function Save-SentCopy { | |||||
| param($Settings, [System.Security.SecureString]$Password, [System.Net.Mail.MailMessage]$Message) | |||||
| $imapHost = [string]$Settings['IMAP_HOST'] | |||||
| if (-not $imapHost) { return $false } | |||||
| $port = 993 | |||||
| if ($Settings['IMAP_PORT'] -and -not [int]::TryParse($Settings['IMAP_PORT'], [ref]$port)) { | |||||
| throw 'Invalid IMAP_PORT' | |||||
| } | |||||
| $folder = if ($Settings['IMAP_SENT_FOLDER']) { $Settings['IMAP_SENT_FOLDER'] } else { 'Sent' } | |||||
| $mime = Get-MessageMimeBytes $Message | |||||
| $tcp = New-Object System.Net.Sockets.TcpClient | |||||
| $plain = $null | |||||
| try { | |||||
| $tcp.ReceiveTimeout = 20000 | |||||
| $tcp.SendTimeout = 20000 | |||||
| $tcp.Connect($imapHost, $port) | |||||
| $ssl = New-Object System.Net.Security.SslStream($tcp.GetStream(), $false) | |||||
| $ssl.ReadTimeout = 20000 | |||||
| $ssl.WriteTimeout = 20000 | |||||
| $ssl.AuthenticateAsClient($imapHost) | |||||
| $send = { | |||||
| param([string]$Text) | |||||
| $data = [System.Text.Encoding]::UTF8.GetBytes($Text + "`r`n") | |||||
| $ssl.Write($data, 0, $data.Length) | |||||
| $ssl.Flush() | |||||
| } | |||||
| $null = Read-ImapLine $ssl | |||||
| $plain = (New-Object System.Net.NetworkCredential('', $Password)).Password | |||||
| & $send ('a1 LOGIN ' + (ConvertTo-ImapQuoted $Settings['SMTP_USER']) + ' ' + (ConvertTo-ImapQuoted $plain)) | |||||
| Read-ImapResponse $ssl 'a1' | |||||
| & $send ('a2 APPEND ' + (ConvertTo-ImapQuoted $folder) + ' (\Seen) {' + $mime.Length + '}') | |||||
| $line = Read-ImapLine $ssl | |||||
| if (-not $line.StartsWith('+')) { throw "IMAP APPEND rejected: $line" } | |||||
| $ssl.Write($mime, 0, $mime.Length) | |||||
| $ssl.Write([byte[]](13, 10), 0, 2) | |||||
| $ssl.Flush() | |||||
| Read-ImapResponse $ssl 'a2' | |||||
| & $send 'a3 LOGOUT' | |||||
| return $true | |||||
| } | |||||
| finally { | |||||
| $plain = $null | |||||
| $tcp.Close() | |||||
| } | |||||
| } | |||||
| function Save-SentCopySafely { | |||||
| param($Settings, $Password, $Message) | |||||
| try { | |||||
| if (Save-SentCopy $Settings $Password $Message) { return 'Copy saved to Sent folder.' } | |||||
| return $null | |||||
| } | |||||
| catch { return "Warning: could not save a Sent copy ($($_.Exception.Message))." } | |||||
| } | |||||
| @@ -63,6 +63,7 @@ try { | |||||
| $client.Send($message) | $client.Send($message) | ||||
| Write-CampaignAudit $db 'approved_campaign_test_accepted' ([string]$CampaignId) ("Internal test to $testTo") | Write-CampaignAudit $db 'approved_campaign_test_accepted' ([string]$CampaignId) ("Internal test to $testTo") | ||||
| Write-Output "Approved campaign $CampaignId test accepted for $testTo. Live delivery counts did not advance." | Write-Output "Approved campaign $CampaignId test accepted for $testTo. Live delivery counts did not advance." | ||||
| if ($Send) { $sentNote = Save-SentCopySafely $settings $password $message; if ($sentNote) { Write-Output $sentNote } } | |||||
| } | } | ||||
| finally { | finally { | ||||
| if ($client) { $client.Dispose() } | if ($client) { $client.Dispose() } | ||||
| @@ -58,6 +58,7 @@ try { | |||||
| $client.Send($message) | $client.Send($message) | ||||
| Write-CampaignAudit $db 'campaign_test_accepted' $review.Token ("Internal test to $($review.Identity.TestTo)") | Write-CampaignAudit $db 'campaign_test_accepted' $review.Token ("Internal test to $($review.Identity.TestTo)") | ||||
| Write-Output "Campaign test accepted for $($review.Identity.TestTo). Check the received message before approval." | Write-Output "Campaign test accepted for $($review.Identity.TestTo). Check the received message before approval." | ||||
| if ($Send) { $sentNote = Save-SentCopySafely $settings $password $message; if ($sentNote) { Write-Output $sentNote } } | |||||
| } | } | ||||
| finally { | finally { | ||||
| if ($client) { $client.Dispose() } | if ($client) { $client.Dispose() } | ||||
Powered by TurnKey Linux.