#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.4: Zertifikatsüberwachung, parallele SMTP-Clients, separater Queue-Worker, robuste Queue und Graph-Retry #> [CmdletBinding()] param( [string]$ConfigPath = "$PSScriptRoot\config.json" ) $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 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)) { $logDir = Join-Path $PSScriptRoot $logDir } 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 verhindert kollidierende Schreibzugriffe. $mutex = New-Object System.Threading.Mutex($false, "Local\SMTPGraphRelay-Log") $lockTaken = $false try { $lockTaken = $mutex.WaitOne(5000) if ($lockTaken) { Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8 } } 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 '(?]*)>') { $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 } $recipients.Add($matches[1]) Write-SmtpLine $writer "250 2.1.5 OK" } elseif ($line -match '^(?i)DATA\s*$') { if (-not $mailFrom -or $recipients.Count -eq 0) { Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM and RCPT TO first" 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 } # SMTP dot-stuffing rückgängig machen 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 { [void](Save-SmtpMessage -Lines $data -MailFrom $mailFrom -Recipients $recipients.ToArray() -RemoteAddress $remoteIp.ToString()) Write-SmtpLine $writer "250 2.0.0 Queued" } $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)(AUTH|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 # 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 = @( "Write-Log", "Resolve-PathFromConfig", "Test-IPv4InCidr", "Test-ClientAllowed", "Get-QueueDirectories", "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 = @( "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.3 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." 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." }