[CmdletBinding()] param([string]$ConfigPath = "$PSScriptRoot\config.json"); #Requires -Version 5.1 $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); $files = Get-ChildItem -LiteralPath ($logDir) -File -Filter "SMTPGraphRelay-*.log" -ErrorAction SilentlyContinue; foreach ($file in $files) { 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"; $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); $allowed = @($script:Config.Smtp.AllowedNetworks); foreach ($entry in $allowed) { 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 ); $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; $folderList = @($dirs.Root, $dirs.Incoming, $dirs.Pending, $dirs.Processing, $dirs.Failed); foreach ($dir in $folderList) { New-Item -ItemType Directory -Path ($dir) -Force | Out-Null; } $oldFiles = Get-ChildItem -LiteralPath ($dirs.Root) -Filter "*.eml" -File -ErrorAction SilentlyContinue; foreach ($file in $oldFiles) { $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)"; } $procFiles = Get-ChildItem -LiteralPath ($dirs.Processing) -Filter "*.eml" -File -ErrorAction SilentlyContinue; foreach ($file in $procFiles) { $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) { 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 {} 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 {} if ($null -eq $statusCode) { $msgOut = $ErrorRecord | Out-String; $combined = "$message $msgOut"; 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 ); $list = @($Entries); foreach ($entry in$list) { 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."; } 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 { $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) { 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 ($Writer) -Line "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 ($Writer) -Line "334 UGFzc3dvcmQ6"; $encodedPassword =$Reader.ReadLine(); if ($null -eq$encodedPassword) { throw "Client disconnected during AUTH LOGIN"; } $password = ConvertFrom-Base64Utf8 -Value ($encodedPassword);$isValid = Test-SmtpCredentials -Username ($username) -Password ($password); return [pscustomobject]@{ Valid = $isValid; 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 ($Writer) -Line "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"; } $username = $parts[1]; $password = $parts[2]; $isValid = Test-SmtpCredentials -Username ($username) -Password ($password); return [pscustomobject]@{ Valid = $isValid; 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 { $pendingFiles = Get-ChildItem -LiteralPath ($dirs.Pending) -Filter "*.eml" -File -ErrorAction Stop; $pendingCount = @($pendingFiles).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); $raw = ($messageLines -join "`r`n") + "`r`n"; $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"; } [IO.File]::WriteAllText($incomingEmlTmp, $raw, $latin1); $meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath ($incomingMetaTmp) -Encoding UTF8; 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; }; $rcptStr = $Recipients -join ', '; Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$rcptStr | 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 ($writer) -Line "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; $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); $usersCount = @($authSettings.Users).Count; $authAvailable = ($usersCount -gt 0); Write-SmtpLine -Writer ($writer) -Line ("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 ($writer) -Line ("250-{0}" -f $script:Config.Smtp.Hostname); $sz = [int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024; Write-SmtpLine -Writer ($writer) -Line ("250-SIZE {0}" -f $sz); if ($authAvailable) { Write-SmtpLine -Writer ($writer) -Line "250-AUTH LOGIN PLAIN"; } Write-SmtpLine -Writer ($writer) -Line "250 8BITMIME"; } elseif ($line -match '^(?i)HELO\s+(.+)$') { Write-SmtpLine -Writer ($writer) -Line ("250 {0}" -f $script:Config.Smtp.Hostname); } elseif ($line -match '^(?i)AUTH\s+LOGIN(?:\s+(\S+))?\s*$') { if (-not $authAvailable) { Write-SmtpLine -Writer ($writer) -Line "504 5.7.4 Authentication mechanism unavailable"; continue; } if ($authenticated) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.0 Already authenticated"; continue; } if ($authLocked) { Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "501 5.5.2 Invalid authentication data"; } elseif ($result.Valid) { $authenticated = $true; $authenticatedUser = $result.Username; $authFailures = 0; Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "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 ($writer) -Line "504 5.7.4 Authentication mechanism unavailable"; continue; } if ($authenticated) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.0 Already authenticated"; continue; } if ($authLocked) { Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "501 5.5.2 Invalid authentication data"; } elseif ($result.Valid) { $authenticated = $true; $authenticatedUser = $result.Username; $authFailures = 0; Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "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 ($writer) -Line "504 5.5.4 Unsupported authentication mechanism"; } elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') { if ($authRequired -and -not $authenticated) { Write-SmtpLine -Writer ($writer) -Line "530 5.7.0 Authentication required"; continue; } if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) { Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "250 2.1.0 OK"; } elseif ($line -match '^(?i)RCPT TO:\s*<([^>]+)>') { if (-not $mailFrom) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.1 Need MAIL FROM first"; continue; } if ($recipients.Count -ge $limits.MaxRecipients) { Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "250 2.1.5 OK"; } elseif ($line -match '^(?i)DATA\s*$') { if ($authRequired -and -not $authenticated) { Write-SmtpLine -Writer ($writer) -Line "530 5.7.0 Authentication required"; continue; } if (-not $mailFrom -or $recipients.Count -eq 0) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.1 Need MAIL FROM and RCPT TO first"; continue; } if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) { Write-SmtpLine -Writer ($writer) -Line "452 4.5.3 Too many messages in this session"; continue; } $pressure = Get-QueuePressure; if (-not $pressure.Accept) { Write-SmtpLine -Writer ($writer) -Line ($pressure.SmtpCode); Write-Log ("SMTP-Backpressure fuer {0}: {1}" -f $remoteIp, $pressure.Reason) "WARN"; continue; } Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "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 { $rcptArr = $recipients.ToArray(); $rmtIpStr = $remoteIp.ToString(); $saved = Save-SmtpMessage -Lines ($data) -MailFrom ($mailFrom) -Recipients ($rcptArr) -RemoteAddress ($rmtIpStr) -AuthenticatedUser ($authenticatedUser) -QueueId ($queueId); $acceptedMessages++; Write-SmtpLine -Writer ($writer) -Line ("250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId); } catch { Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "250 2.0.0 Reset"; } elseif ($line -match '^(?i)NOOP(?:\s+.*)?$') { Write-SmtpLine -Writer ($writer) -Line "250 2.0.0 OK"; } elseif ($line -match '^(?i)QUIT\s*$') { Write-SmtpLine -Writer ($writer) -Line "221 2.0.0 Bye"; break; } elseif ($line -match '^(?i)STARTTLS\b') { Write-SmtpLine -Writer ($writer) -Line "502 5.5.1 Command not implemented"; } else { Write-SmtpLine -Writer ($writer) -Line "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 { $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; $fullConfPath = [IO.Path]::GetFullPath($ConfigPath); $script:InstallRoot = Split-Path -Parent ($fullConfPath); $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 {} } Remove-Item -LiteralPath ($script:ShutdownSignalPath) -Force -ErrorAction SilentlyContinue; $logResPath = Resolve-PathFromConfig ($script:Config.Paths.Logs); New-Item -ItemType Directory -Path ($logResPath) -Force | Out-Null; Initialize-QueueDirectories; Invoke-LogMaintenance -Force; [void](Test-RelayCertificateExpiry -ForceLog); $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); $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; $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; $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") { `$sigPathStr = [string]`$script:Config.ShutdownSignalPath; if (Test-Path -LiteralPath (`$sigPathStr)) { Write-Log "Queue-Worker: Shutdown-Signal erkannt."; break; } } try { Process-Queue; } catch { Write-Log ("Queue-Worker: {0}" -f `$_.Exception.Message) "ERROR"; } `$sleepUntil = (Get-Date).AddSeconds([int]`$script:Config.Queue.PollSeconds); while ((Get-Date) -lt `$sleepUntil) { if (`$script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath") { `$sigPathStr = [string]`$script:Config.ShutdownSignalPath; if (Test-Path -LiteralPath (`$sigPathStr)) { 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(); $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"; }; $userCount = @($authSettings.Users).Count; Write-Log ("SMTP-AUTH: {0}; {1} Benutzer; max. {2} Fehlversuche/Verbindung." -f $authMode, $userCount,$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); } 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); 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 {} 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."; }