diff --git a/config.example.ini b/config.example.ini index 5f4e19e..00dff07 100644 --- a/config.example.ini +++ b/config.example.ini @@ -10,6 +10,9 @@ SMTP_FROM=danielcovington@postcardmojo.com SMTP_REPLY_TO=danielcovington@postcardmojo.com SMTP_TEST_TO= SMTP_SECRET_ID=primary_smtp +IMAP_HOST= +IMAP_PORT=993 +IMAP_SENT_FOLDER=Sent DB_PATH=data/MassMailer.mdb DB_PROVIDER=Microsoft.ACE.OLEDB.12.0 DB_BITNESS=x86 @@ -29,6 +32,9 @@ BACKUP_DIR=backups # QUEUE_INTERVAL is the minimum number of seconds between attempts across # 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. +# 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. # 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, diff --git a/tools/common.vbs b/tools/common.vbs index 3357a41..65fef9b 100644 --- a/tools/common.vbs +++ b/tools/common.vbs @@ -63,6 +63,7 @@ Function IsAllowedKey(key) IsAllowedKey = False 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", _ + "IMAP_HOST", "IMAP_PORT", "IMAP_SENT_FOLDER", _ "DB_PATH", "DB_PROVIDER", "DB_BITNESS", "QUEUE_INTERVAL", _ "SEND_MODE", "DISPLAY_TIME_ZONE", "TEMPLATE_ROOT", _ "TEMPLATE_IMAGE_MODE", "TEMPLATE_IMAGE_TYPES", _ diff --git a/tools/dispatch-once.ps1 b/tools/dispatch-once.ps1 index b1a09ee..1b5b1e9 100644 --- a/tools/dispatch-once.ps1 +++ b/tools/dispatch-once.ps1 @@ -163,6 +163,7 @@ try { 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]) { diff --git a/tools/mail-common.ps1 b/tools/mail-common.ps1 index a19fe07..c150ae9 100644 --- a/tools/mail-common.ps1 +++ b/tools/mail-common.ps1 @@ -109,3 +109,113 @@ function Load-SmtpPassword { $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))." } +} diff --git a/tools/send-approved-campaign-test.ps1 b/tools/send-approved-campaign-test.ps1 index 91ac75b..16ed232 100644 --- a/tools/send-approved-campaign-test.ps1 +++ b/tools/send-approved-campaign-test.ps1 @@ -63,6 +63,7 @@ try { $client.Send($message) 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." + if ($Send) { $sentNote = Save-SentCopySafely $settings $password $message; if ($sentNote) { Write-Output $sentNote } } } finally { if ($client) { $client.Dispose() } diff --git a/tools/send-campaign-test.ps1 b/tools/send-campaign-test.ps1 index 50b6ab0..11318e5 100644 --- a/tools/send-campaign-test.ps1 +++ b/tools/send-campaign-test.ps1 @@ -58,6 +58,7 @@ try { $client.Send($message) 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." + if ($Send) { $sentNote = Save-SentCopySafely $settings $password $message; if ($sentNote) { Write-Output $sentNote } } } finally { if ($client) { $client.Dispose() }