diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 new file mode 100644 index 0000000..d9aa8d2 --- /dev/null +++ b/SMTPGraphRelay.ps1 @@ -0,0 +1,2210 @@ +[CmdletBinding()] +param( + [string]$ConfigPath = "$PSScriptRoot\config.json" +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +function Get-LogSettings { + $maxSizeMB = 10 + $retentionDays = 30 + $cleanupHours = 12 + + try { + if ($script:Config.Logging) { + if ($script:Config.Logging.PSObject.Properties.Name -contains "MaxFileSizeMB") { + $v = [int]$script:Config.Logging.MaxFileSizeMB + if ($v -ge 1 -and $v -le 1024) { $maxSizeMB = $v } + } + + if ($script:Config.Logging.PSObject.Properties.Name -contains "RetentionDays") { + $v = [int]$script:Config.Logging.RetentionDays + if ($v -ge 1 -and $v -le 3650) { $retentionDays = $v } + } + + if ($script:Config.Logging.PSObject.Properties.Name -contains "CleanupHours") { + $v = [int]$script:Config.Logging.CleanupHours + if ($v -ge 1 -and $v -le 168) { $cleanupHours = $v } + } + } + } catch {} + + return [pscustomobject]@{ + MaxFileSizeMB = $maxSizeMB + RetentionDays = $retentionDays + CleanupHours = $cleanupHours + } +} + +function Invoke-LogMaintenance { + param( + [switch]$Force + ) + + if (-not $script:Config -or -not $script:Config.Paths.Logs) { + return + } + + $settings = Get-LogSettings + $logDir = $script:Config.Paths.Logs + + if (-not [IO.Path]::IsPathRooted($logDir)) { + if ($PSScriptRoot) { + $logDir = Join-Path $PSScriptRoot $logDir + } else { + return + } + } + + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + + if (-not $script:NextLogCleanup) { + $script:NextLogCleanup = [DateTime]::MinValue + } + + if (-not $Force -and (Get-Date) -lt $script:NextLogCleanup) { + return + } + + $cutoff = (Get-Date).AddDays(-1 * $settings.RetentionDays) + + foreach ($file in Get-ChildItem -LiteralPath $logDir -File -Filter "SMTPGraphRelay-*.log" -ErrorAction SilentlyContinue) { + if ($file.LastWriteTime -lt $cutoff) { + try { + Remove-Item -LiteralPath $file.FullName -Force -ErrorAction Stop + } catch {} + } + } + + $script:NextLogCleanup = (Get-Date).AddHours($settings.CleanupHours) +} + +function Rotate-LogIfNeeded { + param( + [Parameter(Mandatory)][string]$LogFile + ) + + if (-not (Test-Path -LiteralPath $LogFile)) { + return + } + + $settings = Get-LogSettings + + try { + $file = Get-Item -LiteralPath $LogFile -ErrorAction Stop + $maxBytes = [int64]$settings.MaxFileSizeMB * 1024 * 1024 + + if ($file.Length -lt $maxBytes) { + return + } + + $stamp = Get-Date -Format "yyyyMMdd-HHmmss" + $dir = Split-Path $LogFile -Parent + $archive = Join-Path $dir "SMTPGraphRelay-$stamp.log" + + $counter = 1 + while (Test-Path -LiteralPath $archive) { + $archive = Join-Path $dir ("SMTPGraphRelay-{0}-{1}.log" -f $stamp, $counter) + $counter++ + } + + Move-Item -LiteralPath $LogFile -Destination $archive -Force + } + catch {} +} + +function Write-Log { + param( + [Parameter(Mandatory)][string]$Message, + [ValidateSet("INFO","WARN","ERROR","DEBUG")][string]$Level = "INFO" + ) + + $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" + $line = "[$ts] [$Level] $Message" + Write-Host $line + + try { + if ($script:Config -and $script:Config.Paths.Logs) { + $logDir = $script:Config.Paths.Logs + + if (-not [IO.Path]::IsPathRooted($logDir)) { + if ($PSScriptRoot) { + $logDir = Join-Path $PSScriptRoot $logDir + } else { + return + } + } + + New-Item -ItemType Directory -Path $logDir -Force | Out-Null + $logFile = Join-Path $logDir "SMTPGraphRelay.log" + + # Mehrere SMTP-Runspaces und der Queue-Worker koennen gleichzeitig loggen. + # Ein benannter Mutex schuetzt Rotation und Schreibzugriff gemeinsam. + $mutex = New-Object System.Threading.Mutex($false, "Local\SMTPGraphRelay-Log") + $lockTaken = $false + + try { + $lockTaken = $mutex.WaitOne(5000) + + if ($lockTaken) { + Rotate-LogIfNeeded -LogFile $logFile + Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8 + Invoke-LogMaintenance + } + } + finally { + if ($lockTaken) { + try { $mutex.ReleaseMutex() } catch {} + } + + $mutex.Dispose() + } + } + } + catch {} +} + +function Resolve-PathFromConfig { + param([Parameter(Mandatory)][string]$Path) + if ([IO.Path]::IsPathRooted($Path)) { return $Path } + return (Join-Path $PSScriptRoot $Path) +} + +function Test-IPv4InCidr { + param( + [Parameter(Mandatory)][System.Net.IPAddress]$Address, + [Parameter(Mandatory)][string]$Cidr + ) + + if ($Address.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) { + return $false + } + + if ($Cidr -notmatch '^(.+)/(\d{1,2}); $') { return $false } + + try { + $network = [System.Net.IPAddress]::Parse($matches[1]) + } catch { + return $false + } + + if ($network.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) { + return $false + } + + $prefix = [int]$matches[2] + if ($prefix -lt 0 -or $prefix -gt 32) { return $false } + + $ipBytes = $Address.GetAddressBytes() + $netBytes = $network.GetAddressBytes() + + for ($i = 0; $i -lt 4; $i++) { + $remaining = $prefix - ($i * 8) + if ($remaining -le 0) { break } + + $bits = [Math]::Min(8, $remaining) + [byte]$mask = (0xFF -shl (8 - $bits)) -band 0xFF + + if (($ipBytes[$i] -band $mask) -ne ($netBytes[$i] -band $mask)) { + return $false + } + } + + return $true +} + +function Test-ClientAllowed { + param([Parameter(Mandatory)][System.Net.IPAddress]$Address) + + foreach ($entry in @($script:Config.Smtp.AllowedNetworks)) { + if ($entry -eq "*") { return $true } + try { + if ($entry -match '/') { + if (Test-IPv4InCidr -Address $Address -Cidr $entry) { return $true } + } elseif ([System.Net.IPAddress]::Parse($entry).Equals($Address)) { + return $true + } + } catch {} + } + return $false +} + +function Get-GraphConnection { + if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { + throw "Microsoft.Graph.Authentication ist nicht installiert." + } + + Import-Module Microsoft.Graph.Authentication -ErrorAction Stop + + $cert = Get-Item -LiteralPath ("Cert:\LocalMachine\My\{0}" -f $script:Config.Graph.CertificateThumbprint) -ErrorAction Stop + + $ctx = Get-MgContext + $needsConnect = $true + if ($ctx) { + if ($ctx.ClientId -eq $script:Config.Graph.ClientId -and $ctx.TenantId -eq $script:Config.Graph.TenantId -and $ctx.AuthType -eq "AppOnly") { + $needsConnect = $false + } + } + + if ($needsConnect) { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + Connect-MgGraph ` + -TenantId $script:Config.Graph.TenantId ` + -ClientId $script:Config.Graph.ClientId ` + -Certificate $cert ` + -NoWelcome | Out-Null + } +} + +function Set-MimeSender { + param( + [Parameter(Mandatory)][byte[]]$MimeBytes, + [Parameter(Mandatory)][string]$Sender + ) + + # Headerbereich als Latin1 lesen, damit Bytes 1:1 erhalten bleiben. + $latin1 = [System.Text.Encoding]::GetEncoding(28591) + $text = $latin1.GetString($MimeBytes) + $separator = "`r`n`r`n" + $idx = $text.IndexOf($separator) + if ($idx -lt 0) { + $separator = "`n`n" + $idx = $text.IndexOf($separator) + } + if ($idx -lt 0) { return $MimeBytes } + + $headers = $text.Substring(0, $idx) + $body = $text.Substring($idx + $separator.Length) + + if ($headers -match '(?im)^From:.*(?:\r?\n[ \t].*)*') { + $headers = [regex]::Replace( + $headers, + '(?im)^From:.*(?:\r?\n[ \t].*)*', + "From: <$Sender>", + 1 + ) + } else { + $headers = "From: <$Sender>`r`n" + $headers + } + + return $latin1.GetBytes($headers + "`r`n`r`n" + $body) +} + +function Get-QueueDirectories { + $queueRoot = Resolve-PathFromConfig $script:Config.Paths.Queue + $failedDir = Resolve-PathFromConfig $script:Config.Paths.Failed + + return [pscustomobject]@{ + Root = $queueRoot + Incoming = Join-Path $queueRoot "incoming" + Pending = Join-Path $queueRoot "pending" + Processing = Join-Path $queueRoot "processing" + Failed = $failedDir + } +} + +function Initialize-QueueDirectories { + $dirs = Get-QueueDirectories + + foreach ($dir in @( + $dirs.Root, + $dirs.Incoming, + $dirs.Pending, + $dirs.Processing, + $dirs.Failed + )) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null + } + + # Alte V1-Mails direkt aus queue\ nach pending migrieren. + foreach ($file in Get-ChildItem -LiteralPath $dirs.Root -Filter "*.eml" -File -ErrorAction SilentlyContinue) { + $target = Join-Path $dirs.Pending $file.Name + if (-not (Test-Path -LiteralPath $target)) { + Move-Item -LiteralPath $file.FullName -Destination $target -Force + } + + $oldMeta = "$($file.FullName).json" + if (Test-Path -LiteralPath $oldMeta) { + $targetMeta = "$target.json" + if (-not (Test-Path -LiteralPath $targetMeta)) { + Move-Item -LiteralPath $oldMeta -Destination $targetMeta -Force + } + } + + Write-Log "Alte Queue-Mail nach pending migriert: $($file.Name)" + } + + # Nach Absturz/Neustart koennen Dateien in processing liegen. + # Sie werden wieder nach pending gestellt und spaeter erneut versucht. + foreach ($file in Get-ChildItem -LiteralPath $dirs.Processing -Filter "*.eml" -File -ErrorAction SilentlyContinue) { + $pendingPath = Join-Path $dirs.Pending $file.Name + $processingMeta = "$($file.FullName).json" + $pendingMeta = "$pendingPath.json" + + if (Test-Path -LiteralPath $processingMeta) { + Move-Item -LiteralPath $processingMeta -Destination $pendingMeta -Force + } + + Move-Item -LiteralPath $file.FullName -Destination $pendingPath -Force + Write-Log "Processing-Mail nach Neustart zurueck nach pending gestellt: $($file.Name)" "WARN" + } +} + +function Get-GraphFailureInfo { + param( + [Parameter(Mandatory)] + $ErrorRecord + ) + + $statusCode = $null + $retryAfterSeconds = $null + $message = $ErrorRecord.Exception.Message + + try { + $response = $ErrorRecord.Exception.Response + + if ($response) { + try { + if ($null -ne $response.StatusCode) { + $statusCode = [int]$response.StatusCode + } + } catch {} + + try { + $headers = $response.Headers + + if ($headers) { + # HttpResponseMessage / HttpResponseHeaders + try { + $values = $null + if ($headers.TryGetValues("Retry-After", [ref]$values)) { + $raw = @($values)[0] + if ($raw -match '^\d+$') { + $retryAfterSeconds = [int]$raw + } else { + $retryDate = [DateTimeOffset]::Parse($raw) + $seconds = [Math]::Ceiling(($retryDate - [DateTimeOffset]::UtcNow).TotalSeconds) + if ($seconds -gt 0) { + $retryAfterSeconds = [int]$seconds + } + } + } + } catch {} + + # WebResponse-artige Header + if ($null -eq $retryAfterSeconds) { + try { + $raw = $headers["Retry-After"] + if ($raw) { + if ($raw -match '^\d+$') { + $retryAfterSeconds = [int]$raw + } else { + $retryDate = [DateTimeOffset]::Parse($raw) + $seconds = [Math]::Ceiling(($retryDate - [DateTimeOffset]::UtcNow).TotalSeconds) + if ($seconds -gt 0) { + $retryAfterSeconds = [int]$seconds + } + } + } + } catch {} + } + } + } catch {} + } + } catch {} + + # Fallback: Statuscode aus Text extrahieren, falls das Graph-Modul ihn nur dort liefert. + if ($null -eq $statusCode) { + $combined = "$message $($ErrorRecord | Out-String)" + if ($combined -match '(?" + $result.Add("Message-ID: $messageId") + } + + for ($i = 0; $i -lt $Lines.Count; $i++) { + $result.Add($Lines[$i]) + } + + if ($separatorIndex -lt 0) { + $result.Add("") + } + + return $result +} + + +function Test-AddressInList { + param( + [Parameter(Mandatory)][System.Net.IPAddress]$Address, + [object[]]$Entries + ) + + foreach ($entry in @($Entries)) { + if ($null -eq $entry) { continue } + + $value = [string]$entry + if ([string]::IsNullOrWhiteSpace($value)) { continue } + + if ($value -eq "*") { return $true } + + try { + if ($value -match '/') { + if (Test-IPv4InCidr -Address $Address -Cidr $value) { + return $true + } + } + elseif ([System.Net.IPAddress]::Parse($value).Equals($Address)) { + return $true + } + } + catch {} + } + + return $false +} + +function Get-SmtpAuthSettings { + $requireAuth = $false + $maxFailures = 5 + $allowUnauthenticatedNetworks = @() + $users = @() + + try { + if ($script:Config.Smtp.PSObject.Properties.Name -contains "RequireAuth") { + $requireAuth = [bool]$script:Config.Smtp.RequireAuth + } + + if ($script:Config.Smtp.PSObject.Properties.Name -contains "AuthMaxFailures") { + $v = [int]$script:Config.Smtp.AuthMaxFailures + if ($v -ge 1 -and $v -le 100) { + $maxFailures = $v + } + } + + if ($script:Config.Smtp.PSObject.Properties.Name -contains "AllowUnauthenticatedNetworks") { + $allowUnauthenticatedNetworks = @($script:Config.Smtp.AllowUnauthenticatedNetworks) + } + + if ($script:Config.Smtp.PSObject.Properties.Name -contains "AuthUsers") { + $users = @($script:Config.Smtp.AuthUsers) + } + } + catch {} + + return [pscustomobject]@{ + RequireAuth = $requireAuth + AuthMaxFailures = $maxFailures + AllowUnauthenticatedNetworks = $allowUnauthenticatedNetworks + Users = $users + } +} + +function Test-SmtpAuthenticationRequired { + param([Parameter(Mandatory)][System.Net.IPAddress]$Address) + + $settings = Get-SmtpAuthSettings + + if (-not $settings.RequireAuth) { + return $false + } + + if (Test-AddressInList -Address $Address -Entries $settings.AllowUnauthenticatedNetworks) { + return $false + } + + return $true +} + +function ConvertFrom-Base64Utf8 { + param([Parameter(Mandatory)][string]$Value) + + try { + $bytes = [Convert]::FromBase64String($Value) + return [Text.Encoding]::UTF8.GetString($bytes) + } + catch { + throw "Invalid Base64 data" + } +} + +function Invoke-Pbkdf2Sha256 { + param( + [Parameter(Mandatory)][string]$Password, + [Parameter(Mandatory)][byte[]]$Salt, + [Parameter(Mandatory)][int]$Iterations, + [int]$Length = 32 + ) + + if ($Iterations -lt 1) { + throw "Iterations must be greater than zero." + } + + # Auf unterstuetzten .NET-Framework-Versionen verwenden wir die native, + # schnelle PBKDF2-SHA256-Implementierung. + try { + $derive = New-Object System.Security.Cryptography.Rfc2898DeriveBytes( + $Password, + $Salt, + $Iterations, + [System.Security.Cryptography.HashAlgorithmName]::SHA256 + ) + + try { + return $derive.GetBytes($Length) + } + finally { + $derive.Dispose() + } + } + catch { + # Kompatibilitaets-Fallback fuer aeltere .NET-Framework-Staende. + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + $hmac.Key = [Text.Encoding]::UTF8.GetBytes($Password) + + try { + $hashLength = 32 + $blocks = [Math]::Ceiling($Length / [double]$hashLength) + $output = New-Object byte[] ($blocks * $hashLength) + $offset = 0 + + for ($block = 1; $block -le $blocks; $block++) { + $blockBytes = [BitConverter]::GetBytes([int]$block) + if ([BitConverter]::IsLittleEndian) { + [Array]::Reverse($blockBytes) + } + + $input = New-Object byte[] ($Salt.Length + 4) + [Array]::Copy($Salt, 0, $input, 0, $Salt.Length) + [Array]::Copy($blockBytes, 0, $input, $Salt.Length, 4) + + $u = $hmac.ComputeHash($input) + $t = New-Object byte[] $u.Length + [Array]::Copy($u, $t, $u.Length) + + for ($i = 2; $i -le $Iterations; $i++) { + $u = $hmac.ComputeHash($u) + for ($j = 0; $j -lt $t.Length; $j++) { + $t[$j] = $t[$j] -bxor $u[$j] + } + } + + [Array]::Copy($t, 0, $output, $offset, $t.Length) + $offset += $t.Length + } + + $result = New-Object byte[] $Length + [Array]::Copy($output, 0, $result, 0, $Length) + return $result + } + finally { + $hmac.Dispose() + } + } +} + +function Test-FixedTimeEquals { + param( + [Parameter(Mandatory)][byte[]]$A, + [Parameter(Mandatory)][byte[]]$B + ) + + if ($A.Length -ne $B.Length) { + return $false + } + + [int]$difference = 0 + for ($i = 0; $i -lt $A.Length; $i++) { + $difference = $difference -bor ($A[$i] -bxor $B[$i]) + } + + return ($difference -eq 0) +} + +function Test-SmtpCredentials { + param( + [Parameter(Mandatory)][string]$Username, + [Parameter(Mandatory)][string]$Password + ) + + if ([string]::IsNullOrWhiteSpace($Username)) { + return $false + } + + $settings = Get-SmtpAuthSettings + $user = $settings.Users | + Where-Object { ([string]$_.Username).Equals($Username, [StringComparison]::OrdinalIgnoreCase) } | + Select-Object -First 1 + + if (-not $user) { + # Bewusst keine Unterscheidung im SMTP-Result zwischen unbekanntem + # Benutzer und falschem Passwort. + return $false + } + + try { + $salt = [Convert]::FromBase64String([string]$user.Salt) + $expected = [Convert]::FromBase64String([string]$user.PasswordHash) + $iterations = [int]$user.Iterations + + if ($iterations -lt 10000 -or $expected.Length -lt 16 -or $salt.Length -lt 8) { + return $false + } + + $actual = Invoke-Pbkdf2Sha256 ` + -Password $Password ` + -Salt $salt ` + -Iterations $iterations ` + -Length $expected.Length + + return (Test-FixedTimeEquals -A $actual -B$expected) + } + catch { + return $false + } +} + +function Invoke-SmtpAuthLogin { + param( + [Parameter(Mandatory)][System.IO.StreamReader]$Reader, + [Parameter(Mandatory)][System.IO.StreamWriter]$Writer, + [string]$InitialResponse + ) + + try { + if ([string]::IsNullOrWhiteSpace($InitialResponse)) { + Write-SmtpLine $Writer "334 VXNlcm5hbWU6" + $encodedUser =$Reader.ReadLine() + if ($null -eq$encodedUser) { throw "Client disconnected during AUTH LOGIN" } + } + else { + $encodedUser =$InitialResponse + } + + $username = ConvertFrom-Base64Utf8 -Value$encodedUser + + Write-SmtpLine $Writer "334 UGFzc3dvcmQ6" + $encodedPassword =$Reader.ReadLine() + if ($null -eq$encodedPassword) { throw "Client disconnected during AUTH LOGIN" } + + $password = ConvertFrom-Base64Utf8 -Value$encodedPassword + + return [pscustomobject]@{ + Valid = (Test-SmtpCredentials -Username $username -Password$password) + Username = $username + ProtocolError = $false + } + } + catch { + return [pscustomobject]@{ + Valid = $false + Username = $null + ProtocolError = $true + } + } +} + +function Invoke-SmtpAuthPlain { + param( + [Parameter(Mandatory)][System.IO.StreamReader]$Reader, + [Parameter(Mandatory)][System.IO.StreamWriter]$Writer, + [string]$InitialResponse + ) + + try { + $encoded =$InitialResponse + + if ([string]::IsNullOrWhiteSpace($encoded)) { + Write-SmtpLine $Writer "334" + $encoded =$Reader.ReadLine() + if ($null -eq$encoded) { throw "Client disconnected during AUTH PLAIN" } + } + + $decoded = ConvertFrom-Base64Utf8 -Value$encoded + $parts =$decoded -split "`0", 3 + + if ($parts.Count -lt 3) { + throw "Invalid AUTH PLAIN payload" + } + + # RFC 4616: [authzid] NUL authcid NUL passwd + $username = $parts[1] + $password = $parts[2] + + return [pscustomobject]@{ + Valid = (Test-SmtpCredentials -Username $username -Password $password) + Username = $username + ProtocolError = $false + } + } + catch { + return [pscustomobject]@{ + Valid = $false + Username = $null + ProtocolError = $true + } + } +} + + +function Get-DebugSettings { + $defaults = [ordered]@{ + Enabled = $false + DelayBeforeGraphSendSeconds = 0 + SimulateGraphStatus = 0 + SimulateQueueWriteFailure = $false + OverridePendingCount = -1 + OverrideFreeDiskSpaceMB = -1 + } + + try { + if (-not ($script:Config.PSObject.Properties.Name -contains "Debug") -or -not $script:Config.Debug) { + return [pscustomobject]$defaults + } + + $d = $script:Config.Debug + + if ($d.PSObject.Properties.Name -contains "Enabled") { + $defaults.Enabled = [bool]$d.Enabled + } + + if ($d.PSObject.Properties.Name -contains "DelayBeforeGraphSendSeconds") { + $v = [int]$d.DelayBeforeGraphSendSeconds + if ($v -ge 0 -and $v -le 300) { $defaults.DelayBeforeGraphSendSeconds = $v } + } + + if ($d.PSObject.Properties.Name -contains "SimulateGraphStatus") { + $v = [int]$d.SimulateGraphStatus + if ($v -eq 0 -or ($v -ge 400 -and $v -le 599)) { $defaults.SimulateGraphStatus = $v } + } + + if ($d.PSObject.Properties.Name -contains "SimulateQueueWriteFailure") { + $defaults.SimulateQueueWriteFailure = [bool]$d.SimulateQueueWriteFailure + } + + if ($d.PSObject.Properties.Name -contains "OverridePendingCount") { + $v = [int]$d.OverridePendingCount + if ($v -ge -1 -and $v -le 1000000) { $defaults.OverridePendingCount = $v } + } + + if ($d.PSObject.Properties.Name -contains "OverrideFreeDiskSpaceMB") { + $v = [int]$d.OverrideFreeDiskSpaceMB + if ($v -ge -1 -and $v -le 1048576) { $defaults.OverrideFreeDiskSpaceMB = $v } + } + } + catch {} + + return [pscustomobject]$defaults +} + +function Get-SmtpLimits { + $maxRecipients = 50 + $maxMessagesPerConnection = 25 + $maxPendingMessages = 5000 + $minFreeDiskSpaceMB = 1024 + + try { + if ($script:Config.Smtp.PSObject.Properties.Name -contains "MaxRecipients") { + $v = [int]$script:Config.Smtp.MaxRecipients + if ($v -ge 1 -and $v -le 1000) { $maxRecipients = $v } + } + + if ($script:Config.Smtp.PSObject.Properties.Name -contains "MaxMessagesPerConnection") { + $v = [int]$script:Config.Smtp.MaxMessagesPerConnection + if ($v -ge 1 -and $v -le 10000) { $maxMessagesPerConnection = $v } + } + + if ($script:Config.Queue.PSObject.Properties.Name -contains "MaxPendingMessages") { + $v = [int]$script:Config.Queue.MaxPendingMessages + if ($v -ge 1 -and $v -le 1000000) { $maxPendingMessages = $v } + } + + if ($script:Config.Queue.PSObject.Properties.Name -contains "MinFreeDiskSpaceMB") { + $v = [int]$script:Config.Queue.MinFreeDiskSpaceMB + if ($v -ge 0 -and $v -le 1048576) { $minFreeDiskSpaceMB = $v } + } + } + catch {} + + return [pscustomobject]@{ + MaxRecipients = $maxRecipients + MaxMessagesPerConnection = $maxMessagesPerConnection + MaxPendingMessages = $maxPendingMessages + MinFreeDiskSpaceMB = $minFreeDiskSpaceMB + } +} + +function Get-QueuePressure { + $dirs = Get-QueueDirectories + $limits = Get-SmtpLimits + + $debug = Get-DebugSettings + + try { + if ($debug.Enabled -and $debug.OverridePendingCount -ge 0) { + $pendingCount = [int]$debug.OverridePendingCount + Write-Log ("QA: PendingCount wird mit {0} simuliert." -f $pendingCount) "WARN" + } + else { + $pendingCount = @( + Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File -ErrorAction Stop + ).Count + } + } + catch { + return [pscustomobject]@{ + Accept = $false + SmtpCode = "451 4.3.0 SMTPGraphRelay queue temporarily unavailable" + Reason = "Pending-Queue konnte nicht gelesen werden: $($_.Exception.Message)" + PendingCount = $null + FreeSpaceMB = $null + } + } + + if ($pendingCount -ge $limits.MaxPendingMessages) { + return [pscustomobject]@{ + Accept = $false + SmtpCode = "452 4.3.1 SMTPGraphRelay queue limit reached" + Reason = "Pending-Queue-Limit erreicht ($pendingCount/$($limits.MaxPendingMessages))" + PendingCount = $pendingCount + FreeSpaceMB = $null + } + } + + $freeSpaceMB = $null + + try { + $fullQueuePath = [IO.Path]::GetFullPath($dirs.Root) + $root = [IO.Path]::GetPathRoot($fullQueuePath) + + if ($root) { + $driveInfo = New-Object System.IO.DriveInfo($root) + if ($driveInfo.IsReady) { + if ($debug.Enabled -and $debug.OverrideFreeDiskSpaceMB -ge 0) { + $freeSpaceMB = [int]$debug.OverrideFreeDiskSpaceMB + Write-Log ("QA: freier Speicher wird mit {0} MB simuliert." -f $freeSpaceMB) "WARN" + } + else { + $freeSpaceMB = [Math]::Floor($driveInfo.AvailableFreeSpace / 1MB) + } + } + } + } + catch { + return [pscustomobject]@{ + Accept = $false + SmtpCode = "451 4.3.0 SMTPGraphRelay storage status unavailable" + Reason = "Freier Speicher konnte nicht ermittelt werden: $($_.Exception.Message)" + PendingCount = $pendingCount + FreeSpaceMB = $null + } + } + + if ($null -ne $freeSpaceMB -and $freeSpaceMB -lt $limits.MinFreeDiskSpaceMB) { + return [pscustomobject]@{ + Accept = $false + SmtpCode = "452 4.3.1 Insufficient system storage" + Reason = "Freier Speicher zu niedrig (${freeSpaceMB}MB < $($limits.MinFreeDiskSpaceMB)MB)" + PendingCount = $pendingCount + FreeSpaceMB = $freeSpaceMB + } + } + + return [pscustomobject]@{ + Accept = $true + SmtpCode = $null + Reason = "OK" + PendingCount = $pendingCount + FreeSpaceMB = $freeSpaceMB + } +} + +function Save-SmtpMessage { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [System.Collections.Generic.List[string]]$Lines, + + [Parameter(Mandatory)] + [string]$MailFrom, + + [Parameter(Mandatory)] + [string[]]$Recipients, + + [Parameter(Mandatory)] + [string]$RemoteAddress, + + [string]$AuthenticatedUser, + + [string]$QueueId + ) + + $dirs = Get-QueueDirectories + + if ([string]::IsNullOrWhiteSpace($QueueId)) { + $QueueId = New-QueueId + } + + $incomingEmlTmp = Join-Path $dirs.Incoming "$QueueId.eml.tmp" + $incomingMetaTmp = Join-Path $dirs.Incoming "$QueueId.eml.json.tmp" + + $pendingEml = Join-Path $dirs.Pending "$QueueId.eml" + $pendingMeta = "$pendingEml.json" + + $messageLines = Add-RelayMessageHeaders ` + -Lines $Lines ` + -QueueId $QueueId ` + -RemoteAddress $RemoteAddress + + # SMTP DATA wird in CRLF normalisiert. + $raw = ($messageLines -join "`r`n") + "`r`n" + + # --------------------------------------------------------- + # BUGFIX: ISO-8859-1 (Latin1) Byte-Passthrough verwenden + # --------------------------------------------------------- + $latin1 = [System.Text.Encoding]::GetEncoding(28591) + + $meta = [ordered]@{ + QueueId = $QueueId + ReceivedUtc = [DateTime]::UtcNow.ToString("o") + RemoteAddress = $RemoteAddress + AuthenticatedUser = $AuthenticatedUser + EnvelopeFrom = $MailFrom + EnvelopeRecipients = @($Recipients) + RetryCount = 0 + NextAttemptUtc = [DateTime]::UtcNow.ToString("o") + } + + try { + $debug = Get-DebugSettings + if ($debug.Enabled -and$debug.SimulateQueueWriteFailure) { + Write-Log ("[{0}] QA: simulierter Queue-Schreibfehler." -f $QueueId) "WARN" + throw "QA simulated queue write failure" + } + + # Erst vollstaendig in incoming schreiben (als Latin1 um alle 8-Bit Zeichen 1:1 zu behalten). + [IO.File]::WriteAllText($incomingEmlTmp, $raw,$latin1) + + $meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath$incomingMetaTmp -Encoding UTF8 + + # Metadaten zuerst finalisieren, Maildatei zuletzt. + # Erst wenn die .eml in pending liegt, ist sie fuer den Worker sichtbar. + Move-Item -LiteralPath $incomingMetaTmp -Destination$pendingMeta -Force + Move-Item -LiteralPath $incomingEmlTmp -Destination$pendingEml -Force + } + catch { + Remove-Item -LiteralPath $incomingEmlTmp -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $incomingMetaTmp -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $pendingMeta -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $pendingEml -Force -ErrorAction SilentlyContinue + throw + } + + $authText = if ([string]::IsNullOrWhiteSpace($AuthenticatedUser)) { "unauthenticated" } else { $AuthenticatedUser } + Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress | Auth=$authText" + return [pscustomobject]@{ + QueueId = $QueueId + Path = $pendingEml + } +} + +function Write-SmtpLine { + param( + [Parameter(Mandatory)][System.IO.StreamWriter]$Writer, + [Parameter(Mandatory)][string]$Line + ) + $Writer.WriteLine($Line); $Writer.Flush(); +} + +function Handle-SmtpClient { + param([Parameter(Mandatory)][System.Net.Sockets.TcpClient]$Client) + + $remote =$Client.Client.RemoteEndPoint + $remoteIp = ([System.Net.IPEndPoint]$remote).Address + Write-Log "SMTP-Verbindung von $remoteIp" + + if (-not (Test-ClientAllowed -Address $remoteIp)) { + try { + $stream = $Client.GetStream(); $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII); $writer.NewLine = "`r`n" + $writer.AutoFlush =$true + Write-SmtpLine $writer "554 5.7.1 Client not allowed" + } catch {} + + $Client.Close() + Write-Log "Client abgewiesen: $remoteIp" "WARN" + return + } + + $stream =$Client.GetStream() + $stream.ReadTimeout = [int]$script:Config.Smtp.ClientTimeoutSeconds * 1000 + + # --------------------------------------------------------- + # BUGFIX: Scanner senden oft ISO-8859-1. Wir nutzen Codepage + # 28591 (Latin1) anstatt UTF-8 um eingehende Bytes 1:1 durchzureichen. + # --------------------------------------------------------- + $latin1 = [System.Text.Encoding]::GetEncoding(28591); $reader = New-Object System.IO.StreamReader( + $stream,$latin1, + $false, 4096,$true + ) + + $writer = New-Object System.IO.StreamWriter($stream, + [System.Text.Encoding]::ASCII, + 4096, + $true + ) + + $writer.NewLine = "`r`n" + $writer.AutoFlush =$true + + $mailFrom =$null + $recipients = New-Object System.Collections.Generic.List[string]$acceptedMessages = 0 + $limits = Get-SmtpLimits$authSettings = Get-SmtpAuthSettings + + $authenticated = $false$authenticatedUser = $null$authFailures = 0 + $authLocked =$false + $authRequired = Test-SmtpAuthenticationRequired -Address$remoteIp + $authAvailable = (@($authSettings.Users).Count -gt 0) + + Write-SmtpLine $writer ("220 {0} SMTPGraphRelay ready" -f $script:Config.Smtp.Hostname) + + try { + while ($Client.Connected) { + $line =$reader.ReadLine() + if ($null -eq$line) { break } + + if ($line -match '^(?i)EHLO\s+(.+); $') { + Write-SmtpLine $writer ("250-{0}" -f $script:Config.Smtp.Hostname) + Write-SmtpLine $writer ("250-SIZE {0}" -f ([int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024)) + + if ($authAvailable) { + Write-SmtpLine $writer "250-AUTH LOGIN PLAIN" + } + + Write-SmtpLine $writer "250 8BITMIME" + } + elseif ($line -match '^(?i)HELO\s+(.+); $') { + Write-SmtpLine $writer ("250 {0}" -f $script:Config.Smtp.Hostname) + } + elseif ($line -match '^(?i)AUTH\s+LOGIN(?:\s+(\S+))?\s*$') { + if (-not $authAvailable) { + Write-SmtpLine $writer "504 5.7.4 Authentication mechanism unavailable" + continue + } + + if ($authenticated) { + Write-SmtpLine $writer "503 5.5.0 Already authenticated" + continue + } + + if ($authLocked) { + Write-SmtpLine $writer "454 4.7.0 Too many authentication failures" + continue + } + + $initial =$matches[1] + $result = Invoke-SmtpAuthLogin -Reader$reader -Writer $writer -InitialResponse$initial + + if ($result.ProtocolError) {$authFailures++ + Write-SmtpLine $writer "501 5.5.2 Invalid authentication data" + } + elseif ($result.Valid) { + $authenticated =$true + $authenticatedUser =$result.Username + $authFailures = 0 + Write-SmtpLine $writer "235 2.7.0 Authentication successful" + Write-Log ("SMTP-AUTH LOGIN erfolgreich | Client={0} | Benutzer={1}" -f $remoteIp,$authenticatedUser) + continue + } + else { + $authFailures++ + Write-SmtpLine $writer "535 5.7.8 Authentication credentials invalid" + Write-Log ("SMTP-AUTH LOGIN fehlgeschlagen | Client={0} | Benutzer={1}" -f $remoteIp,$result.Username) "WARN" + } + + if ($authFailures -ge$authSettings.AuthMaxFailures) { + $authLocked =$true + Write-Log ("SMTP-AUTH gesperrt nach {0} Fehlversuchen | Client={1}" -f $authFailures,$remoteIp) "WARN" + } + } + elseif ($line -match '^(?i)AUTH\s+PLAIN(?:\s+(\S+))?\s*$') { + if (-not $authAvailable) { + Write-SmtpLine $writer "504 5.7.4 Authentication mechanism unavailable" + continue + } + + if ($authenticated) { + Write-SmtpLine $writer "503 5.5.0 Already authenticated" + continue + } + + if ($authLocked) { + Write-SmtpLine $writer "454 4.7.0 Too many authentication failures" + continue + } + + $initial =$matches[1] + $result = Invoke-SmtpAuthPlain -Reader$reader -Writer $writer -InitialResponse$initial + + if ($result.ProtocolError) {$authFailures++ + Write-SmtpLine $writer "501 5.5.2 Invalid authentication data" + } + elseif ($result.Valid) { + $authenticated =$true + $authenticatedUser =$result.Username + $authFailures = 0 + Write-SmtpLine $writer "235 2.7.0 Authentication successful" + Write-Log ("SMTP-AUTH PLAIN erfolgreich | Client={0} | Benutzer={1}" -f $remoteIp,$authenticatedUser) + continue + } + else { + $authFailures++ + Write-SmtpLine $writer "535 5.7.8 Authentication credentials invalid" + Write-Log ("SMTP-AUTH PLAIN fehlgeschlagen | Client={0} | Benutzer={1}" -f $remoteIp,$result.Username) "WARN" + } + + if ($authFailures -ge$authSettings.AuthMaxFailures) { + $authLocked =$true + Write-Log ("SMTP-AUTH gesperrt nach {0} Fehlversuchen | Client={1}" -f $authFailures,$remoteIp) "WARN" + } + } + elseif ($line -match '^(?i)AUTH\b') { + Write-SmtpLine $writer "504 5.5.4 Unsupported authentication mechanism" + } + elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') { + if ($authRequired -and -not$authenticated) { + Write-SmtpLine $writer "530 5.7.0 Authentication required" + continue + } + + if ($acceptedMessages -ge$limits.MaxMessagesPerConnection) { + Write-SmtpLine $writer "452 4.5.3 Too many messages in this session" + Write-Log ("SMTP-Session von {0}: Nachrichtenlimit {1} erreicht." -f $remoteIp,$limits.MaxMessagesPerConnection) "WARN" + continue + } + + $mailFrom = $matches[1]$recipients.Clear() + Write-SmtpLine $writer "250 2.1.0 OK" + } + elseif ($line -match '^(?i)RCPT TO:\s*<([^>]+)>') { + if (-not $mailFrom) { + Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM first" + continue + } + + if ($recipients.Count -ge$limits.MaxRecipients) { + Write-SmtpLine $writer "452 4.5.3 Too many recipients" + Write-Log ("SMTP-Session von {0}: Empfaengerlimit {1} erreicht." -f $remoteIp,$limits.MaxRecipients) "WARN" + continue + } + + $recipients.Add($matches[1]) + Write-SmtpLine $writer "250 2.1.5 OK" + } + elseif ($line -match '^(?i)DATA\s*$') { + if ($authRequired -and -not$authenticated) { + Write-SmtpLine $writer "530 5.7.0 Authentication required" + continue + } + + if (-not $mailFrom -or$recipients.Count -eq 0) { + Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM and RCPT TO first" + continue + } + + if ($acceptedMessages -ge$limits.MaxMessagesPerConnection) { + Write-SmtpLine $writer "452 4.5.3 Too many messages in this session" + continue + } + + $pressure = Get-QueuePressure + if (-not $pressure.Accept) { + Write-SmtpLine $writer$pressure.SmtpCode + Write-Log ("SMTP-Backpressure fuer {0}: {1}" -f $remoteIp,$pressure.Reason) "WARN" + continue + } + + Write-SmtpLine $writer "354 End data with ." + + $data = New-Object System.Collections.Generic.List[string]$size = 0 + $maxBytes = [int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024 + $tooLarge =$false + + while ($true) { + $dataLine =$reader.ReadLine() + + if ($null -eq$dataLine) { + throw "Client disconnected during DATA" + } + + if ($dataLine -eq ".") { + break + } + + if ($dataLine.StartsWith("..")) { + $dataLine =$dataLine.Substring(1) + } + + $size += $latin1.GetByteCount($dataLine) + 2 + + if ($size -gt$maxBytes) { + $tooLarge =$true + } + elseif (-not $tooLarge) { + $data.Add($dataLine) + } + } + + if ($tooLarge) { + Write-SmtpLine $writer "552 5.3.4 Message size exceeds fixed maximum message size" + Write-Log "Mail von $remoteIp wegen Groessenlimit verworfen." "WARN" + } + else { + $queueId = New-QueueId + + try { + $saved = Save-SmtpMessage ` + -Lines $data ` + -MailFrom $mailFrom ` + -Recipients $recipients.ToArray() ` + -RemoteAddress $remoteIp.ToString() ` + -AuthenticatedUser $authenticatedUser ` + -QueueId $queueId + + $acceptedMessages++ + + Write-SmtpLine $writer ( + "250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId + ) + } + catch { + Write-SmtpLine $writer "451 4.3.0 SMTPGraphRelay queue temporarily unavailable" + + Write-Log ( + "[{0}] Queue-Speicherung fehlgeschlagen | Client={1} | Fehler={2}" -f ` + $queueId, + $remoteIp, + $_.Exception.Message + ) "ERROR" + } + } + + $mailFrom = $null + $recipients.Clear() + } + elseif ($line -match '^(?i)RSET\s*$') { + $mailFrom = $null + $recipients.Clear() + Write-SmtpLine $writer "250 2.0.0 Reset" + } + elseif ($line -match '^(?i)NOOP(?:\s+.*)?$') { + Write-SmtpLine $writer "250 2.0.0 OK" + } + elseif ($line -match '^(?i)QUIT\s*$') { + Write-SmtpLine $writer "221 2.0.0 Bye" + break + } + elseif ($line -match '^(?i)STARTTLS\b') { + Write-SmtpLine $writer "502 5.5.1 Command not implemented" + } + else { + Write-SmtpLine $writer "500 5.5.2 Command unrecognized" + } + } + } + catch { + Write-Log "SMTP-Clientfehler ${remoteIp}: $($_.Exception.Message)" "WARN" + } + finally { + try { $reader.Dispose() } catch {} + try { $writer.Dispose() } catch {} + try { $stream.Dispose() } catch {} + try { $Client.Close() } catch {} + } +} + +function Get-RelayCertificateStatus { + $thumbprint = [string]$script:Config.Graph.CertificateThumbprint + + if ([string]::IsNullOrWhiteSpace($thumbprint)) { + throw "Graph.CertificateThumbprint fehlt in config.json." + } + + $certPath = "Cert:\LocalMachine\My\$thumbprint" + $cert = Get-Item -LiteralPath $certPath -ErrorAction Stop + + if (-not $cert.HasPrivateKey) { + throw "Relay-Zertifikat '$thumbprint' besitzt keinen privaten Schluessel." + } + + $remaining = $cert.NotAfter.ToUniversalTime() - [DateTime]::UtcNow + + return [pscustomobject]@{ + Certificate = $cert + Thumbprint = $cert.Thumbprint + Subject = $cert.Subject + NotBefore = $cert.NotBefore + NotAfter = $cert.NotAfter + DaysRemaining = [Math]::Floor($remaining.TotalDays) + HoursRemaining = [Math]::Floor($remaining.TotalHours) + Expired = ($remaining.TotalSeconds -le 0) + } +} + +function Test-RelayCertificateExpiry { + param( + [switch]$ForceLog + ) + + try { + $status = Get-RelayCertificateStatus + + $warningDays = 60 + $criticalDays = 14 + + if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateWarningDays") { + try { $warningDays = [int]$script:Config.Graph.CertificateWarningDays } catch {} + } + + if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateCriticalDays") { + try { $criticalDays = [int]$script:Config.Graph.CertificateCriticalDays } catch {} + } + + if ($status.Expired) { + Write-Log ("KRITISCH: Graph-Zertifikat {0} ist seit {1} abgelaufen!" -f ` + $status.Thumbprint, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "ERROR" + return $status + } + + if ($status.DaysRemaining -le$criticalDays) { + Write-Log ("KRITISCH: Graph-Zertifikat laeuft in {0} Tagen ab ({1}). Bitte Zertifikat erneuern." -f ` + $status.DaysRemaining, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "ERROR" + } + elseif ($status.DaysRemaining -le $warningDays) { + Write-Log ("WARNUNG: Graph-Zertifikat laeuft in {0} Tagen ab ({1}). Zertifikatsrotation einplanen." -f ` + $status.DaysRemaining, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "WARN" + } + elseif ($ForceLog) { + Write-Log ("Graph-Zertifikat gueltig bis {0} ({1} Tage verbleibend)." -f ` + $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss"), $status.DaysRemaining) + } + + return $status + } + catch { + Write-Log ("Zertifikatspruefung fehlgeschlagen: {0}" -f $_.Exception.Message) "ERROR" + return $null + } +} + +function Get-FunctionBootstrap { + param( + [Parameter(Mandatory)] + [string[]]$FunctionNames + ) + + $parts = New-Object System.Collections.Generic.List[string] + + foreach ($name in $FunctionNames) { + $item = Get-Item -LiteralPath ("Function:\{0}" -f $name) -ErrorAction Stop + $parts.Add(("function {0} {{`r`n{1}`r`n}}" -f $name, $item.Definition)) + } + + return ($parts -join "`r`n`r`n") +} + +function New-WorkerConfig { + # Runspaces haben keinen verlaesslichen $PSScriptRoot des Hauptskripts. + # Deshalb werden alle Pfade fuer Worker einmal absolut aufgeloest. + $copy = $script:Config | ConvertTo-Json -Depth 20 | ConvertFrom-Json + + $copy.Paths.Queue = Resolve-PathFromConfig $script:Config.Paths.Queue + $copy.Paths.Failed = Resolve-PathFromConfig $script:Config.Paths.Failed + $copy.Paths.Logs = Resolve-PathFromConfig $script:Config.Paths.Logs + + if ($script:ShutdownSignalPath) { + $copy | Add-Member -NotePropertyName ShutdownSignalPath -NotePropertyValue $script:ShutdownSignalPath -Force + } + + return $copy +} + +function Send-ServiceBusyAndClose { + param( + [Parameter(Mandatory)] + [System.Net.Sockets.TcpClient]$Client + ) + + try { + $stream = $Client.GetStream(); $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII, 1024, $true) + $writer.NewLine = "`r`n" + $writer.AutoFlush = $true + $writer.WriteLine("421 4.3.2 SMTPGraphRelay busy, try again later") + $writer.Flush() + $writer.Dispose() + $stream.Dispose() + } + catch {} + finally { + try { $Client.Close() } catch {} + } +} + +function Remove-CompletedSmtpWorkers { + for ($i = $script:ActiveSmtpWorkers.Count - 1; $i -ge 0; $i--) { + $worker = $script:ActiveSmtpWorkers[$i] + + if ($worker.AsyncResult.IsCompleted) { + try { + [void]$worker.PowerShell.EndInvoke($worker.AsyncResult) + } + catch { + Write-Log ("SMTP-Worker fuer {0} wurde mit Fehler beendet: {1}" -f $worker.RemoteAddress, $_.Exception.Message) "WARN" + } + finally { + try { $worker.PowerShell.Dispose() } catch {} + $script:ActiveSmtpWorkers.RemoveAt($i) + } + } + } +} + +function Start-SmtpClientWorker { + param( + [Parameter(Mandatory)] + [System.Net.Sockets.TcpClient]$Client + ) + + Remove-CompletedSmtpWorkers + + $remoteAddress = "unknown" + try { + $remoteAddress = ([System.Net.IPEndPoint]$Client.Client.RemoteEndPoint).Address.ToString() + } catch {} + + if ($script:ActiveSmtpWorkers.Count -ge $script:MaxConcurrentClients) { + Write-Log ("SMTP-Verbindung von {0} abgewiesen: Parallel-Limit {1} erreicht." -f $remoteAddress, $script:MaxConcurrentClients) "WARN" + Send-ServiceBusyAndClose -Client $Client + return + } + + $ps = [System.Management.Automation.PowerShell]::Create() + $ps.RunspacePool = $script:SmtpRunspacePool + + [void]$ps.AddScript($script:SmtpWorkerScript) + [void]$ps.AddArgument($Client) + [void]$ps.AddArgument($script:WorkerConfig) + + try { + $async = $ps.BeginInvoke() + + [void]$script:ActiveSmtpWorkers.Add([pscustomobject]@{ + PowerShell = $ps + AsyncResult = $async + Client = $Client + RemoteAddress = $remoteAddress + StartedUtc = [DateTime]::UtcNow + }) + } + catch { + try { $ps.Dispose() } catch {} + try { $Client.Close() } catch {} + throw + } +} + +if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "Konfiguration nicht gefunden: $ConfigPath. Bitte zuerst Setup-SMTPGraphRelay.ps1 ausfuehren." +} + +$script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json + +$script:InstallRoot = Split-Path -Parent ([IO.Path]::GetFullPath($ConfigPath)) +$script:ShutdownSignalPath = Join-Path $script:InstallRoot "shutdown.request" +$script:GracefulShutdownSeconds = 30 + +if ($script:Config.Smtp.PSObject.Properties.Name -contains "GracefulShutdownSeconds") { + try { + $configuredGrace = [int]$script:Config.Smtp.GracefulShutdownSeconds + if ($configuredGrace -ge 5 -and $configuredGrace -le 300) { + $script:GracefulShutdownSeconds = $configuredGrace + } + } catch {} +} + +# Ein altes Signal darf einen frisch gestarteten Relay nicht sofort wieder beenden. +Remove-Item -LiteralPath $script:ShutdownSignalPath -Force -ErrorAction SilentlyContinue + +New-Item -ItemType Directory -Path (Resolve-PathFromConfig $script:Config.Paths.Logs) -Force | Out-Null +Initialize-QueueDirectories + +# Alte Logarchive beim Start direkt bereinigen. +Invoke-LogMaintenance -Force + +# Zertifikat beim Start immer pruefen und Status protokollieren. +[void](Test-RelayCertificateExpiry -ForceLog) + +# Pruefintervall optional per config, Standard 12 Stunden. +$script:CertificateCheckHours = 12 +if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateCheckHours") { + try { + $configuredHours = [int]$script:Config.Graph.CertificateCheckHours + if ($configuredHours -ge 1 -and $configuredHours -le 168) { + $script:CertificateCheckHours = $configuredHours + } + } catch {} +} +$script:NextCertificateCheck = (Get-Date).AddHours($script:CertificateCheckHours) + +# MaxConcurrentClients ist optional, damit bestehende config.json-Dateien unveraendert weiterlaufen. +$script:MaxConcurrentClients = 20 +if ($script:Config.Smtp.PSObject.Properties.Name -contains "MaxConcurrentClients") { + try { + $configuredMax = [int]$script:Config.Smtp.MaxConcurrentClients + if ($configuredMax -ge 1 -and $configuredMax -le 200) { + $script:MaxConcurrentClients = $configuredMax + } + else { + Write-Log "Ungueltiges Smtp.MaxConcurrentClients; verwende Standard 20." "WARN" + } + } + catch { + Write-Log "Smtp.MaxConcurrentClients konnte nicht gelesen werden; verwende Standard 20." "WARN" + } +} + +$script:WorkerConfig = New-WorkerConfig + +# --------------------------------------------------------------------------- +# SMTP Runspace Pool +# --------------------------------------------------------------------------- +# Nur die Funktionen, die ein SMTP-Client wirklich benoetigt, werden in die +# Worker-Runspaces kopiert. Graph-/Queue-Versand bleibt in einem separaten Worker. +$smtpFunctionNames = @( + "Get-LogSettings", + "Invoke-LogMaintenance", + "Rotate-LogIfNeeded", + "Write-Log", + "Resolve-PathFromConfig", + "Test-IPv4InCidr", + "Test-ClientAllowed", + "Get-QueueDirectories", + "New-QueueId", + "Add-RelayMessageHeaders", + "Test-AddressInList", + "Get-SmtpAuthSettings", + "Test-SmtpAuthenticationRequired", + "ConvertFrom-Base64Utf8", + "Invoke-Pbkdf2Sha256", + "Test-FixedTimeEquals", + "Test-SmtpCredentials", + "Invoke-SmtpAuthLogin", + "Invoke-SmtpAuthPlain", + "Get-DebugSettings", + "Get-SmtpLimits", + "Get-QueuePressure", + "Save-SmtpMessage", + "Write-SmtpLine", + "Handle-SmtpClient" +) + +$smtpBootstrap = Get-FunctionBootstrap -FunctionNames $smtpFunctionNames +$script:SmtpWorkerScript = @" +param(`$Client, `$Config) +`$script:Config = `$Config + +$smtpBootstrap + +Handle-SmtpClient -Client `$Client +"@ + +$script:SmtpRunspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool( 1,$script:MaxConcurrentClients +) +$script:SmtpRunspacePool.Open(); $script:ActiveSmtpWorkers = New-Object System.Collections.ArrayList + +# --------------------------------------------------------------------------- +# Separater Queue-/Graph-Worker +# --------------------------------------------------------------------------- +# Damit ein langsamer Graph-Aufruf nicht mehr die Annahme neuer SMTP-Verbindungen +# blockiert, laeuft Process-Queue dauerhaft in einem eigenen Runspace. +$queueFunctionNames = @( + "Get-LogSettings", + "Invoke-LogMaintenance", + "Rotate-LogIfNeeded", + "Write-Log", + "Resolve-PathFromConfig", + "Get-GraphConnection", + "Set-MimeSender", + "Get-QueueDirectories", + "Get-GraphFailureInfo", + "Get-RetryDecision", + "Get-DebugSettings", + "Move-QueueItem", + "Send-QueuedMail", + "Process-Queue" +) + +$queueBootstrap = Get-FunctionBootstrap -FunctionNames $queueFunctionNames$queueWorkerScript = @" +param(`$Config) +`$script:Config = `$Config + +$queueBootstrap + +try { + while (`$true) { + if (`$script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath" -and + (Test-Path -LiteralPath ([string]`$script:Config.ShutdownSignalPath))) { + Write-Log "Queue-Worker: Shutdown-Signal erkannt." + break + } + + try { + Process-Queue + } + catch { + Write-Log ("Queue-Worker: {0}" -f `$_.Exception.Message) "ERROR" + } + + # In kleinen Schritten schlafen, damit Shutdown nicht erst nach PollSeconds reagiert. + `$sleepUntil = (Get-Date).AddSeconds([int]`$script:Config.Queue.PollSeconds) + while ((Get-Date) -lt `$sleepUntil) { + if (`$script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath" -and + (Test-Path -LiteralPath ([string]`$script:Config.ShutdownSignalPath))) { + break + } + Start-Sleep -Milliseconds 250 + } + } +} +finally { + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} +} +"@ + +$script:QueuePowerShell = [System.Management.Automation.PowerShell]::Create() +[void]$script:QueuePowerShell.AddScript($queueWorkerScript) +[void]$script:QueuePowerShell.AddArgument($script:WorkerConfig) +$script:QueueAsyncResult =$script:QueuePowerShell.BeginInvoke() + +# --------------------------------------------------------------------------- +# Listener +# --------------------------------------------------------------------------- +$listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress) +$listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port); $listener.Start() + +Write-Log "SMTPGraphRelay V1.9 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" +Write-Log "Graph-Absender: $($script:Config.Graph.SenderMailbox)" +Write-Log "Maximale parallele SMTP-Verbindungen: $script:MaxConcurrentClients" +Write-Log "Queue-/Graph-Worker laeuft separat vom SMTP-Listener." + +$limits = Get-SmtpLimits +Write-Log ("SMTP-Limits: max. {0} Empfaenger/Mail, {1} Mails/Verbindung." -f ` + $limits.MaxRecipients, $limits.MaxMessagesPerConnection) +Write-Log ("Backpressure: max. {0} Pending-Mails, mindestens {1} MB freier Speicher." -f ` + $limits.MaxPendingMessages, $limits.MinFreeDiskSpaceMB) + +$authSettings = Get-SmtpAuthSettings +$authMode = if ($authSettings.RequireAuth) { "erforderlich" } else { "optional/deaktiviert" } +Write-Log ("SMTP-AUTH: {0}; {1} Benutzer; max. {2} Fehlversuche/Verbindung." -f ` + $authMode, @($authSettings.Users).Count, $authSettings.AuthMaxFailures) + +Write-Log ("Graceful Shutdown: bis zu {0} Sekunden fuer aktive Sessions/Worker." -f ` + $script:GracefulShutdownSeconds) + +$debugSettings = Get-DebugSettings +if ($debugSettings.Enabled) { + Write-Log "ACHTUNG: QA-/Debug-Testmodus ist AKTIV." "WARN" +} + +$logSettings = Get-LogSettings +Write-Log ("Log-Rotation: max. {0} MB pro Datei, Aufbewahrung {1} Tage." -f ` + $logSettings.MaxFileSizeMB, $logSettings.RetentionDays) + +try { + while ($true) { + Remove-CompletedSmtpWorkers + + if (Test-Path -LiteralPath $script:ShutdownSignalPath) { + Write-Log "Graceful Shutdown angefordert. SMTP-Listener wird geschlossen." + try { $listener.Stop() } catch {} + break + } + + if ((Get-Date) -ge $script:NextCertificateCheck) { + [void](Test-RelayCertificateExpiry) + $script:NextCertificateCheck = (Get-Date).AddHours($script:CertificateCheckHours) + } + + # Sollte der Queue-Worker unerwartet beendet werden, ist das nur dann ein + # Fehler, wenn gerade KEIN geplanter Shutdown laeuft. + if ($script:QueueAsyncResult.IsCompleted) { + if (Test-Path -LiteralPath $script:ShutdownSignalPath) { + break + } + + try { + [void]$script:QueuePowerShell.EndInvoke($script:QueueAsyncResult) + throw "Queue-Worker wurde unerwartet beendet." + } + catch { + throw "Queue-Worker wurde unerwartet beendet: $($_.Exception.Message)" + } + } + + if ($listener.Pending()) { + $client = $listener.AcceptTcpClient() + Start-SmtpClientWorker -Client $client + } + else { + Start-Sleep -Milliseconds 100 + } + } +} +finally { + Write-Log "SMTPGraphRelay faehrt kontrolliert herunter..." + + try { $listener.Stop() } catch {} + + $deadline = (Get-Date).AddSeconds($script:GracefulShutdownSeconds) + + # Laufende SMTP-Sessions bekommen Zeit, ihre aktuelle Nachricht/Session sauber + # abzuscliessen. Es werden bereits keine neuen Verbindungen mehr angenommen. + while ($script:ActiveSmtpWorkers.Count -gt 0 -and (Get-Date) -lt $deadline) { + Remove-CompletedSmtpWorkers + + if ($script:ActiveSmtpWorkers.Count -gt 0) { + Start-Sleep -Milliseconds 200 + } + } + + if ($script:ActiveSmtpWorkers.Count -gt 0) { + Write-Log ("Graceful-Shutdown-Timeout: {0} SMTP-Session(s) werden jetzt beendet." -f ` + $script:ActiveSmtpWorkers.Count) "WARN" + + foreach ($worker in @($script:ActiveSmtpWorkers)) { + try { $worker.Client.Close() } catch {} + try { $worker.PowerShell.Stop() } catch {} + try { + if ($worker.AsyncResult) { + [void]$worker.PowerShell.EndInvoke($worker.AsyncResult) + } + } catch {} + try { $worker.PowerShell.Dispose() } catch {} + } + + $script:ActiveSmtpWorkers.Clear() + } + + try { $script:SmtpRunspacePool.Close() } catch {} + try { $script:SmtpRunspacePool.Dispose() } catch {} + + # Queue-Worker darf den aktuell laufenden Graph-Aufruf beenden. Durch das + # Shutdown-Signal startet Process-Queue anschliessend keine weitere Mail. + while (-not $script:QueueAsyncResult.IsCompleted -and (Get-Date) -lt$deadline) { + Start-Sleep -Milliseconds 200 + } + + if (-not $script:QueueAsyncResult.IsCompleted) { + Write-Log "Graceful-Shutdown-Timeout: Queue-/Graph-Worker wird jetzt beendet." "WARN" + try { $script:QueuePowerShell.Stop() } catch {} + } + + try { + if ($script:QueueAsyncResult) { + [void]$script:QueuePowerShell.EndInvoke($script:QueueAsyncResult) + } + } catch {} + + try { $script:QueuePowerShell.Dispose() } catch {} + try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} + + Remove-Item -LiteralPath $script:ShutdownSignalPath -Force -ErrorAction SilentlyContinue + Write-Log "SMTPGraphRelay sauber beendet." +} + + +