$ErrorActionPreference = 'Stop' function Get-MailContext { param([string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini')) $settings = @{} foreach ($line in Get-Content -LiteralPath $ConfigPath -Encoding UTF8) { $trimmed = $line.Trim() if (-not $trimmed -or $trimmed.StartsWith('#') -or $trimmed.StartsWith(';')) { continue } $parts = $trimmed.Split('=', 2) if ($parts.Count -ne 2) { throw "Invalid configuration line in $ConfigPath" } $name = $parts[0].Trim().ToUpperInvariant() if ($settings.ContainsKey($name)) { throw "Duplicate setting: $name" } $settings[$name] = $parts[1].Trim() } foreach ($name in @('DB_PATH', 'DB_PROVIDER', 'DB_BITNESS', 'SMTP_SECRET_ID', 'SMTP_USER')) { if (-not $settings.ContainsKey($name) -or -not $settings[$name]) { throw "Missing setting: $name" } } if ($settings['DB_PROVIDER'] -notin @('Microsoft.Jet.OLEDB.4.0', 'Microsoft.ACE.OLEDB.12.0', 'Microsoft.ACE.OLEDB.16.0')) { throw 'Unsupported DB_PROVIDER' } $actualBitness = if ([IntPtr]::Size -eq 4) { 'x86' } else { 'x64' } if ($settings['DB_BITNESS'] -ne $actualBitness) { throw "Run this setup under $($settings['DB_BITNESS']) PowerShell" } $databasePath = $settings['DB_PATH'] if ($databasePath.StartsWith('\\')) { throw 'DB_PATH must be on a local drive' } if (-not [System.IO.Path]::IsPathRooted($databasePath)) { $databasePath = Join-Path (Split-Path -Parent $PSScriptRoot) $databasePath } $databasePath = [System.IO.Path]::GetFullPath($databasePath) if ([System.IO.Path]::GetExtension($databasePath) -ne '.mdb') { throw 'DB_PATH must end in .mdb' } if (-not (Test-Path -LiteralPath $databasePath -PathType Leaf)) { throw "MDB not found: $databasePath" } return @{ Settings = $settings; DatabasePath = $databasePath } } function Open-MailDatabase { param($Context) $builder = New-Object System.Data.OleDb.OleDbConnectionStringBuilder $builder.Provider = $Context.Settings['DB_PROVIDER'] $builder['Data Source'] = $Context.DatabasePath $connection = New-Object System.Data.OleDb.OleDbConnection($builder.ConnectionString) $connection.Open() return $connection } function Save-SmtpPassword { param($Context, [System.Security.SecureString]$Password) $encrypted = ConvertFrom-SecureString -SecureString $Password $bytes = [System.Text.Encoding]::UTF8.GetBytes($encrypted) $connection = Open-MailDatabase $Context try { $check = $connection.CreateCommand() $check.CommandText = 'SELECT COUNT(*) FROM [ProtectedSecret] WHERE [Id]=?' $null = $check.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100) $check.Parameters[0].Value = $Context.Settings['SMTP_SECRET_ID'] $exists = [int]$check.ExecuteScalar() -gt 0 if ($exists) { $command = $connection.CreateCommand() $command.CommandText = 'UPDATE [ProtectedSecret] SET [DpapiCiphertext]=?, [RotatedAtUtc]=? WHERE [Id]=?' $null = $command.Parameters.Add('ciphertext', [System.Data.OleDb.OleDbType]::LongVarBinary, $bytes.Length) $command.Parameters[0].Value = $bytes $null = $command.Parameters.Add('rotated', [System.Data.OleDb.OleDbType]::Date) $command.Parameters[1].Value = [DateTime]::UtcNow $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100) $command.Parameters[2].Value = $Context.Settings['SMTP_SECRET_ID'] } else { $command = $connection.CreateCommand() $command.CommandText = 'INSERT INTO [ProtectedSecret] ([Id],[Purpose],[DpapiCiphertext],[CreatedAtUtc]) VALUES (?,?,?,?)' $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100) $command.Parameters[0].Value = $Context.Settings['SMTP_SECRET_ID'] $null = $command.Parameters.Add('purpose', [System.Data.OleDb.OleDbType]::VarWChar, 50) $command.Parameters[1].Value = 'smtp_password' $null = $command.Parameters.Add('ciphertext', [System.Data.OleDb.OleDbType]::LongVarBinary, $bytes.Length) $command.Parameters[2].Value = $bytes $null = $command.Parameters.Add('created', [System.Data.OleDb.OleDbType]::Date) $command.Parameters[3].Value = [DateTime]::UtcNow } $null = $command.ExecuteNonQuery() } finally { $connection.Dispose() [Array]::Clear($bytes, 0, $bytes.Length) } } function Load-SmtpPassword { param($Context) $connection = Open-MailDatabase $Context try { $command = $connection.CreateCommand() $command.CommandText = 'SELECT [DpapiCiphertext] FROM [ProtectedSecret] WHERE [Id]=? AND [Purpose]=?' $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100) $command.Parameters[0].Value = $Context.Settings['SMTP_SECRET_ID'] $null = $command.Parameters.Add('purpose', [System.Data.OleDb.OleDbType]::VarWChar, 50) $command.Parameters[1].Value = 'smtp_password' $bytes = $command.ExecuteScalar() if ($null -eq $bytes -or $bytes -is [DBNull]) { throw 'SMTP password is not configured' } $encrypted = [System.Text.Encoding]::UTF8.GetString($bytes) return ConvertTo-SecureString -String $encrypted } finally { $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))." } }