Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

222 рядки
9.6KB

  1. $ErrorActionPreference = 'Stop'
  2. function Get-MailContext {
  3. param([string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'config.ini'))
  4. $settings = @{}
  5. foreach ($line in Get-Content -LiteralPath $ConfigPath -Encoding UTF8) {
  6. $trimmed = $line.Trim()
  7. if (-not $trimmed -or $trimmed.StartsWith('#') -or $trimmed.StartsWith(';')) { continue }
  8. $parts = $trimmed.Split('=', 2)
  9. if ($parts.Count -ne 2) { throw "Invalid configuration line in $ConfigPath" }
  10. $name = $parts[0].Trim().ToUpperInvariant()
  11. if ($settings.ContainsKey($name)) { throw "Duplicate setting: $name" }
  12. $settings[$name] = $parts[1].Trim()
  13. }
  14. foreach ($name in @('DB_PATH', 'DB_PROVIDER', 'DB_BITNESS', 'SMTP_SECRET_ID', 'SMTP_USER')) {
  15. if (-not $settings.ContainsKey($name) -or -not $settings[$name]) {
  16. throw "Missing setting: $name"
  17. }
  18. }
  19. if ($settings['DB_PROVIDER'] -notin @('Microsoft.Jet.OLEDB.4.0',
  20. 'Microsoft.ACE.OLEDB.12.0', 'Microsoft.ACE.OLEDB.16.0')) {
  21. throw 'Unsupported DB_PROVIDER'
  22. }
  23. $actualBitness = if ([IntPtr]::Size -eq 4) { 'x86' } else { 'x64' }
  24. if ($settings['DB_BITNESS'] -ne $actualBitness) {
  25. throw "Run this setup under $($settings['DB_BITNESS']) PowerShell"
  26. }
  27. $databasePath = $settings['DB_PATH']
  28. if ($databasePath.StartsWith('\\')) { throw 'DB_PATH must be on a local drive' }
  29. if (-not [System.IO.Path]::IsPathRooted($databasePath)) {
  30. $databasePath = Join-Path (Split-Path -Parent $PSScriptRoot) $databasePath
  31. }
  32. $databasePath = [System.IO.Path]::GetFullPath($databasePath)
  33. if ([System.IO.Path]::GetExtension($databasePath) -ne '.mdb') { throw 'DB_PATH must end in .mdb' }
  34. if (-not (Test-Path -LiteralPath $databasePath -PathType Leaf)) {
  35. throw "MDB not found: $databasePath"
  36. }
  37. return @{ Settings = $settings; DatabasePath = $databasePath }
  38. }
  39. function Open-MailDatabase {
  40. param($Context)
  41. $builder = New-Object System.Data.OleDb.OleDbConnectionStringBuilder
  42. $builder.Provider = $Context.Settings['DB_PROVIDER']
  43. $builder['Data Source'] = $Context.DatabasePath
  44. $connection = New-Object System.Data.OleDb.OleDbConnection($builder.ConnectionString)
  45. $connection.Open()
  46. return $connection
  47. }
  48. function Save-SmtpPassword {
  49. param($Context, [System.Security.SecureString]$Password)
  50. $encrypted = ConvertFrom-SecureString -SecureString $Password
  51. $bytes = [System.Text.Encoding]::UTF8.GetBytes($encrypted)
  52. $connection = Open-MailDatabase $Context
  53. try {
  54. $check = $connection.CreateCommand()
  55. $check.CommandText = 'SELECT COUNT(*) FROM [ProtectedSecret] WHERE [Id]=?'
  56. $null = $check.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100)
  57. $check.Parameters[0].Value = $Context.Settings['SMTP_SECRET_ID']
  58. $exists = [int]$check.ExecuteScalar() -gt 0
  59. if ($exists) {
  60. $command = $connection.CreateCommand()
  61. $command.CommandText = 'UPDATE [ProtectedSecret] SET [DpapiCiphertext]=?, [RotatedAtUtc]=? WHERE [Id]=?'
  62. $null = $command.Parameters.Add('ciphertext', [System.Data.OleDb.OleDbType]::LongVarBinary, $bytes.Length)
  63. $command.Parameters[0].Value = $bytes
  64. $null = $command.Parameters.Add('rotated', [System.Data.OleDb.OleDbType]::Date)
  65. $command.Parameters[1].Value = [DateTime]::UtcNow
  66. $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100)
  67. $command.Parameters[2].Value = $Context.Settings['SMTP_SECRET_ID']
  68. }
  69. else {
  70. $command = $connection.CreateCommand()
  71. $command.CommandText = 'INSERT INTO [ProtectedSecret] ([Id],[Purpose],[DpapiCiphertext],[CreatedAtUtc]) VALUES (?,?,?,?)'
  72. $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100)
  73. $command.Parameters[0].Value = $Context.Settings['SMTP_SECRET_ID']
  74. $null = $command.Parameters.Add('purpose', [System.Data.OleDb.OleDbType]::VarWChar, 50)
  75. $command.Parameters[1].Value = 'smtp_password'
  76. $null = $command.Parameters.Add('ciphertext', [System.Data.OleDb.OleDbType]::LongVarBinary, $bytes.Length)
  77. $command.Parameters[2].Value = $bytes
  78. $null = $command.Parameters.Add('created', [System.Data.OleDb.OleDbType]::Date)
  79. $command.Parameters[3].Value = [DateTime]::UtcNow
  80. }
  81. $null = $command.ExecuteNonQuery()
  82. }
  83. finally {
  84. $connection.Dispose()
  85. [Array]::Clear($bytes, 0, $bytes.Length)
  86. }
  87. }
  88. function Load-SmtpPassword {
  89. param($Context)
  90. $connection = Open-MailDatabase $Context
  91. try {
  92. $command = $connection.CreateCommand()
  93. $command.CommandText = 'SELECT [DpapiCiphertext] FROM [ProtectedSecret] WHERE [Id]=? AND [Purpose]=?'
  94. $null = $command.Parameters.Add('id', [System.Data.OleDb.OleDbType]::VarWChar, 100)
  95. $command.Parameters[0].Value = $Context.Settings['SMTP_SECRET_ID']
  96. $null = $command.Parameters.Add('purpose', [System.Data.OleDb.OleDbType]::VarWChar, 50)
  97. $command.Parameters[1].Value = 'smtp_password'
  98. $bytes = $command.ExecuteScalar()
  99. if ($null -eq $bytes -or $bytes -is [DBNull]) { throw 'SMTP password is not configured' }
  100. $encrypted = [System.Text.Encoding]::UTF8.GetString($bytes)
  101. return ConvertTo-SecureString -String $encrypted
  102. }
  103. finally {
  104. $connection.Dispose()
  105. }
  106. }
  107. function Get-MessageMimeBytes {
  108. param([System.Net.Mail.MailMessage]$Message)
  109. # Rewind inline image streams that the SMTP send already consumed.
  110. foreach ($view in $Message.AlternateViews) {
  111. $view.ContentStream.Position = 0
  112. foreach ($resource in $view.LinkedResources) { $resource.ContentStream.Position = 0 }
  113. }
  114. $directory = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString('N'))
  115. $null = New-Item -ItemType Directory -Path $directory
  116. $client = New-Object System.Net.Mail.SmtpClient
  117. try {
  118. $client.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::SpecifiedPickupDirectory
  119. $client.PickupDirectoryLocation = $directory
  120. $client.Send($Message)
  121. $file = Get-ChildItem -LiteralPath $directory -Filter '*.eml' | Select-Object -First 1
  122. return [System.IO.File]::ReadAllBytes($file.FullName)
  123. }
  124. finally {
  125. $client.Dispose()
  126. Remove-Item -LiteralPath $directory -Recurse -Force -ErrorAction SilentlyContinue
  127. }
  128. }
  129. function Read-ImapLine {
  130. param([System.IO.Stream]$Stream)
  131. $bytes = New-Object System.Collections.Generic.List[byte]
  132. while ($true) {
  133. $value = $Stream.ReadByte()
  134. if ($value -lt 0) { throw 'IMAP connection closed' }
  135. if ($value -eq 10) { break }
  136. if ($value -ne 13) { $bytes.Add([byte]$value) }
  137. }
  138. return [System.Text.Encoding]::ASCII.GetString($bytes.ToArray())
  139. }
  140. function Read-ImapResponse {
  141. param([System.IO.Stream]$Stream, [string]$Tag)
  142. while ($true) {
  143. $line = Read-ImapLine $Stream
  144. if ($line.StartsWith("$Tag ")) {
  145. if ($line -notmatch "^$Tag OK") { throw "IMAP command failed: $line" }
  146. return
  147. }
  148. }
  149. }
  150. function ConvertTo-ImapQuoted {
  151. param([string]$Value)
  152. if ($Value -match "[\r\n]") { throw 'Invalid IMAP value' }
  153. return '"' + $Value.Replace('\', '\').Replace('"', '\"') + '"'
  154. }
  155. # Appends a copy of an already-sent message to the Sent folder over IMAP.
  156. # Does nothing unless IMAP_HOST is configured. Callers treat failures as
  157. # non-fatal because the message itself has already been accepted by SMTP.
  158. function Save-SentCopy {
  159. param($Settings, [System.Security.SecureString]$Password, [System.Net.Mail.MailMessage]$Message)
  160. $imapHost = [string]$Settings['IMAP_HOST']
  161. if (-not $imapHost) { return $false }
  162. $port = 993
  163. if ($Settings['IMAP_PORT'] -and -not [int]::TryParse($Settings['IMAP_PORT'], [ref]$port)) {
  164. throw 'Invalid IMAP_PORT'
  165. }
  166. $folder = if ($Settings['IMAP_SENT_FOLDER']) { $Settings['IMAP_SENT_FOLDER'] } else { 'Sent' }
  167. $mime = Get-MessageMimeBytes $Message
  168. $tcp = New-Object System.Net.Sockets.TcpClient
  169. $plain = $null
  170. try {
  171. $tcp.ReceiveTimeout = 20000
  172. $tcp.SendTimeout = 20000
  173. $tcp.Connect($imapHost, $port)
  174. $ssl = New-Object System.Net.Security.SslStream($tcp.GetStream(), $false)
  175. $ssl.ReadTimeout = 20000
  176. $ssl.WriteTimeout = 20000
  177. $ssl.AuthenticateAsClient($imapHost)
  178. $send = {
  179. param([string]$Text)
  180. $data = [System.Text.Encoding]::UTF8.GetBytes($Text + "`r`n")
  181. $ssl.Write($data, 0, $data.Length)
  182. $ssl.Flush()
  183. }
  184. $null = Read-ImapLine $ssl
  185. $plain = (New-Object System.Net.NetworkCredential('', $Password)).Password
  186. & $send ('a1 LOGIN ' + (ConvertTo-ImapQuoted $Settings['SMTP_USER']) + ' ' + (ConvertTo-ImapQuoted $plain))
  187. Read-ImapResponse $ssl 'a1'
  188. & $send ('a2 APPEND ' + (ConvertTo-ImapQuoted $folder) + ' (\Seen) {' + $mime.Length + '}')
  189. $line = Read-ImapLine $ssl
  190. if (-not $line.StartsWith('+')) { throw "IMAP APPEND rejected: $line" }
  191. $ssl.Write($mime, 0, $mime.Length)
  192. $ssl.Write([byte[]](13, 10), 0, 2)
  193. $ssl.Flush()
  194. Read-ImapResponse $ssl 'a2'
  195. & $send 'a3 LOGOUT'
  196. return $true
  197. }
  198. finally {
  199. $plain = $null
  200. $tcp.Close()
  201. }
  202. }
  203. function Save-SentCopySafely {
  204. param($Settings, $Password, $Message)
  205. try {
  206. if (Save-SentCopy $Settings $Password $Message) { return 'Copy saved to Sent folder.' }
  207. return $null
  208. }
  209. catch { return "Warning: could not save a Sent copy ($($_.Exception.Message))." }
  210. }

Powered by TurnKey Linux.