#Requires -Version 5.1 <# .SYNOPSIS SMTPGraphRelay - einfacher SMTP Store-and-Forward Relay zu Microsoft Graph. .DESCRIPTION Nimmt lokale SMTP-Mails an, speichert sie als .eml in einer Queue und sendet sie anschließend per Microsoft Graph sendMail mit App-only Zertifikatsauthentifizierung. V1.7: SMTP AUTH LOGIN/PLAIN mit PBKDF2-SHA256, Benutzer-/Session-Schutz und V1.6 Queue-/Backpressure-Funktionen #> [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 können gleichzeitig loggen. # Ein benannter Mutex schützt 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 können Dateien in processing liegen. # Sie werden wieder nach pending gestellt und später 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 zurück 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 unterstützten .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 { # Kompatibilitäts-Fallback für ältere .NET-Framework-Stände. $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-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 try { $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) { $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" $utf8NoBom = New-Object System.Text.UTF8Encoding($false) $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 { # Erst vollständig in incoming schreiben. [IO.File]::WriteAllText($incomingEmlTmp, $raw, $utf8NoBom) $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 für 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 $reader = New-Object System.IO.StreamReader( $stream, [System.Text.Encoding]::UTF8, $true, 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}: Empfängerlimit {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 für {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 += [System.Text.Encoding]::UTF8.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 Größenlimit 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 Schlüssel." } $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 läuft 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 läuft 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 gültig bis {0} ({1} Tage verbleibend)." -f ` $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss"), $status.DaysRemaining) } return $status } catch { Write-Log ("Zertifikatsprüfung 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 verlässlichen $PSScriptRoot des Hauptskripts. # Deshalb werden alle Pfade für Worker einmal absolut aufgelöst. $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 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 für {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 ausführen." } $script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json 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 prüfen und Status protokollieren. [void](Test-RelayCertificateExpiry -ForceLog) # Prüfintervall 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 unverändert 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 "Ungültiges 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 benötigt, 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-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, läuft 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", "Move-QueueItem", "Send-QueuedMail", "Process-Queue" ) $queueBootstrap = Get-FunctionBootstrap -FunctionNames $queueFunctionNames $queueWorkerScript = @" param(`$Config) `$script:Config = `$Config $queueBootstrap try { while (`$true) { try { Process-Queue } catch { Write-Log ("Queue-Worker: {0}" -f `$_.Exception.Message) "ERROR" } Start-Sleep -Seconds ([int]`$script:Config.Queue.PollSeconds) } } 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.7 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 läuft separat vom SMTP-Listener." $limits = Get-SmtpLimits Write-Log ("SMTP-Limits: max. {0} Empfänger/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) $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 ((Get-Date) -ge $script:NextCertificateCheck) { [void](Test-RelayCertificateExpiry) $script:NextCertificateCheck = (Get-Date).AddHours($script:CertificateCheckHours) } # Sollte der Queue-Worker unerwartet beendet werden, Relay nicht still # ohne Versand weiterlaufen lassen. if ($script:QueueAsyncResult.IsCompleted) { 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 wird beendet..." "INFO" try { $listener.Stop() } catch {} # Neue Clients werden nicht mehr angenommen; bestehende Sessions schließen. 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 {} 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 {} Write-Log "SMTPGraphRelay beendet." }