|
- $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))." }
- }
|