From f70ee9e7639e6fa5d23e6cf520011684e12147d8 Mon Sep 17 00:00:00 2001 From: "manuel.maier" Date: Fri, 14 Aug 2026 22:58:17 +0200 Subject: [PATCH] Dateien nach "/" hochladen --- SMTPGraphRelay.ps1 | 104 +++- Setup-SMTPGraphRelay.ps1 | 16 + Test-SMTPGraphRelay-FailureModes.ps1 | 687 +++++++++++++++++++++++++++ 3 files changed, 801 insertions(+), 6 deletions(-) create mode 100644 Test-SMTPGraphRelay-FailureModes.ps1 diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 index 1b0290d..2011727 100644 --- a/SMTPGraphRelay.ps1 +++ b/SMTPGraphRelay.ps1 @@ -7,7 +7,7 @@ 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.8: Failed-Queue-Verwaltung und kooperativer Graceful Shutdown; inklusive SMTP AUTH und V1.6 Queue-/Backpressure-Funktionen + V1.9: gezielte QA-/Failure-Mode-Testhooks; inklusive Failed-Queue, Graceful Shutdown, SMTP AUTH und Backpressure #> [CmdletBinding()] @@ -580,6 +580,20 @@ function Send-QueuedMail { } try { + $debug = Get-DebugSettings + + if ($debug.Enabled -and $debug.DelayBeforeGraphSendSeconds -gt 0) { + Write-Log ("[{0}] QA: Graph-Versand wird um {1} Sek. verzögert." -f ` + $queueId, $debug.DelayBeforeGraphSendSeconds) "WARN" + Start-Sleep -Seconds ([int]$debug.DelayBeforeGraphSendSeconds) + } + + if ($debug.Enabled -and $debug.SimulateGraphStatus -ge 400) { + Write-Log ("[{0}] QA: simulierter Graph HTTP {1}." -f ` + $queueId, $debug.SimulateGraphStatus) "WARN" + throw ("QA simulated Graph HTTP {0}" -f $debug.SimulateGraphStatus) + } + Get-GraphConnection $sender = [Uri]::EscapeDataString($script:Config.Graph.SenderMailbox) @@ -1128,6 +1142,57 @@ function Invoke-SmtpAuthPlain { } } + +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 @@ -1169,10 +1234,18 @@ function Get-QueuePressure { $dirs = Get-QueueDirectories $limits = Get-SmtpLimits + $debug = Get-DebugSettings + try { - $pendingCount = @( - Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File -ErrorAction Stop - ).Count + if ($debug.Enabled -and $debug.OverridePendingCount -ge 0) { + $pendingCount = [int]$debug.OverridePendingCount + Write-Log ("QA: PendingCount wird mit {0} simuliert." -f $pendingCount) "WARN" + } + else { + $pendingCount = @( + Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File -ErrorAction Stop + ).Count + } } catch { return [pscustomobject]@{ @@ -1203,7 +1276,13 @@ function Get-QueuePressure { if ($root) { $driveInfo = New-Object System.IO.DriveInfo($root) if ($driveInfo.IsReady) { - $freeSpaceMB = [Math]::Floor($driveInfo.AvailableFreeSpace / 1MB) + 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) + } } } } @@ -1290,6 +1369,12 @@ function Save-SmtpMessage { } try { + $debug = Get-DebugSettings + if ($debug.Enabled -and $debug.SimulateQueueWriteFailure) { + Write-Log ("[{0}] QA: simulierter Queue-Schreibfehler." -f $QueueId) "WARN" + throw "QA simulated queue write failure" + } + # Erst vollständig in incoming schreiben. [IO.File]::WriteAllText($incomingEmlTmp, $raw, $utf8NoBom) $meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $incomingMetaTmp -Encoding UTF8 @@ -1918,6 +2003,7 @@ $smtpFunctionNames = @( "Test-SmtpCredentials", "Invoke-SmtpAuthLogin", "Invoke-SmtpAuthPlain", + "Get-DebugSettings", "Get-SmtpLimits", "Get-QueuePressure", "Save-SmtpMessage", @@ -1958,6 +2044,7 @@ $queueFunctionNames = @( "Get-QueueDirectories", "Get-GraphFailureInfo", "Get-RetryDecision", + "Get-DebugSettings", "Move-QueueItem", "Send-QueuedMail", "Process-Queue" @@ -2013,7 +2100,7 @@ $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.8 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" +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 läuft separat vom SMTP-Listener." @@ -2032,6 +2119,11 @@ Write-Log ("SMTP-AUTH: {0}; {1} Benutzer; max. {2} Fehlversuche/Verbindung." -f Write-Log ("Graceful Shutdown: bis zu {0} Sekunden für 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) diff --git a/Setup-SMTPGraphRelay.ps1 b/Setup-SMTPGraphRelay.ps1 index 1939d35..38f500d 100644 --- a/Setup-SMTPGraphRelay.ps1 +++ b/Setup-SMTPGraphRelay.ps1 @@ -46,6 +46,7 @@ $RemoteRelayUrl = "$RepoBaseUrl/raw/branch/main/SMTPGraphRelay.ps1" $ManagedReleaseFiles = @( "SMTPGraphRelay.ps1", "Test-SMTPGraphRelay.ps1", + "Test-SMTPGraphRelay-FailureModes.ps1", "Renew-SMTPGraphRelayCertificate.ps1", "Setup-SMTPGraphRelay.ps1", "version.json", @@ -2076,6 +2077,19 @@ function Manage-SmtpAuth { } } + +function Invoke-FailureModeTests { + Write-Title "SMTPGraphRelay - Failure Mode QA" + + $tester = Join-Path $InstallPath "Test-SMTPGraphRelay-FailureModes.ps1" + if (-not (Test-Path -LiteralPath $tester)) { + Write-Fail "Test-SMTPGraphRelay-FailureModes.ps1 ist nicht installiert." + return + } + + & $tester -InstallPath $InstallPath +} + function Uninstall-Relay { Write-Title "SMTPGraphRelay - Deinstallation" @@ -2241,6 +2255,7 @@ function Show-Menu { Write-Host " [8] Status anzeigen" Write-Host " [9] SMTP-AUTH verwalten" Write-Host " [10] Failed Queue verwalten" + Write-Host " [11] Failure-Mode QA" Write-Host " [0] Beenden" Write-Host "" } @@ -2263,6 +2278,7 @@ while ($true) { "8" { Show-Status } "9" { Manage-SmtpAuth } "10" { Manage-FailedQueue } + "11" { Invoke-FailureModeTests } "0" { break } default { Write-Warn "Ungültige Auswahl." } } diff --git a/Test-SMTPGraphRelay-FailureModes.ps1 b/Test-SMTPGraphRelay-FailureModes.ps1 new file mode 100644 index 0000000..092bf0e --- /dev/null +++ b/Test-SMTPGraphRelay-FailureModes.ps1 @@ -0,0 +1,687 @@ +#Requires -Version 5.1 +#Requires -RunAsAdministrator + +[CmdletBinding()] +param( + [string]$InstallPath = "$env:ProgramFiles\SMTPGraphRelay" +) + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [Text.Encoding]::UTF8 + +$TaskName = "SMTPGraphRelay" +$ConfigPath = Join-Path $InstallPath "config.json" +$ShutdownSignalPath = Join-Path $InstallPath "shutdown.request" + +function Write-Title { + param([string]$Text) + Write-Host "" + Write-Host "==========================================================" -ForegroundColor Cyan + Write-Host " $Text" -ForegroundColor Cyan + Write-Host "==========================================================" -ForegroundColor Cyan + Write-Host "" +} +function Write-Ok { param([string]$Text) Write-Host "[OK] $Text" -ForegroundColor Green } +function Write-Warn { param([string]$Text) Write-Host "[WARN] $Text" -ForegroundColor Yellow } +function Write-Fail { param([string]$Text) Write-Host "[FAIL] $Text" -ForegroundColor Red } +function Write-Info { param([string]$Text) Write-Host "[INFO] $Text" -ForegroundColor Cyan } + +function Get-Config { + if (-not (Test-Path -LiteralPath $ConfigPath)) { + throw "config.json nicht gefunden: $ConfigPath" + } + return Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json +} + +function Save-Config { + param([Parameter(Mandatory)]$Config) + + $tmp = "$ConfigPath.qa.tmp" + $Config | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $tmp -Encoding UTF8 + Move-Item -LiteralPath $tmp -Destination $ConfigPath -Force +} + +function Get-FullPath { + param([string]$Value) + + if ([IO.Path]::IsPathRooted($Value)) { return $Value } + return Join-Path $InstallPath $Value +} + +function Stop-RelayGracefully { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task -or $task.State -ne "Running") { return } + + [IO.File]::WriteAllText( + $ShutdownSignalPath, + [DateTime]::UtcNow.ToString("o"), + (New-Object Text.UTF8Encoding($false)) + ) + + $deadline = (Get-Date).AddSeconds(40) + while ((Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 250 + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task -or $task.State -ne "Running") { + Remove-Item $ShutdownSignalPath -Force -ErrorAction SilentlyContinue + return + } + } + + Write-Warn "Graceful Stop dauerte zu lange; Task wird hart gestoppt." + Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + Remove-Item $ShutdownSignalPath -Force -ErrorAction SilentlyContinue + Start-Sleep -Milliseconds 500 +} + +function Start-Relay { + Remove-Item $ShutdownSignalPath -Force -ErrorAction SilentlyContinue + Start-ScheduledTask -TaskName $TaskName + + $deadline = (Get-Date).AddSeconds(10) + while ((Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 250 + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if ($task -and $task.State -eq "Running") { + return + } + } + + throw "Relay konnte nicht gestartet werden." +} + +function Restart-Relay { + Stop-RelayGracefully + Start-Relay + Start-Sleep -Seconds 1 +} + +function Set-QADebug { + param( + [int]$DelayBeforeGraphSendSeconds = 0, + [int]$SimulateGraphStatus = 0, + [bool]$SimulateQueueWriteFailure = $false, + [int]$OverridePendingCount = -1, + [int]$OverrideFreeDiskSpaceMB = -1 + ) + + $config = Get-Config + + $debug = [pscustomobject]@{ + Enabled = $true + DelayBeforeGraphSendSeconds = $DelayBeforeGraphSendSeconds + SimulateGraphStatus = $SimulateGraphStatus + SimulateQueueWriteFailure = $SimulateQueueWriteFailure + OverridePendingCount = $OverridePendingCount + OverrideFreeDiskSpaceMB = $OverrideFreeDiskSpaceMB + } + + if ($config.PSObject.Properties.Name -contains "Debug") { + $config.Debug = $debug + } + else { + $config | Add-Member -NotePropertyName Debug -NotePropertyValue $debug + } + + Save-Config -Config $config +} + +function Restore-OriginalConfig { + param([Parameter(Mandatory)][string]$BackupPath) + + Copy-Item -LiteralPath $BackupPath -Destination $ConfigPath -Force +} + +function Convert-SecureToPlain { + param([Security.SecureString]$Secure) + + $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($Secure) + try { return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) } + finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } +} + +function Get-SmtpAuthForLocalTest { + $config = Get-Config + $required = $false + + if ($config.Smtp.PSObject.Properties.Name -contains "RequireAuth") { + $required = [bool]$config.Smtp.RequireAuth + } + + if (-not $required) { + return [pscustomobject]@{ Username=$null; Password=$null } + } + + # Prüfen, ob localhost als Ausnahme eingetragen ist. + foreach ($entry in @($config.Smtp.AllowUnauthenticatedNetworks)) { + if ([string]$entry -in @("127.0.0.1","127.0.0.1/32","127.0.0.0/8","*")) { + return [pscustomobject]@{ Username=$null; Password=$null } + } + } + + $user = Read-Host "SMTP-Benutzer für QA-Test" + $secure = Read-Host "SMTP-Passwort" -AsSecureString + $plain = Convert-SecureToPlain -Secure $secure + return [pscustomobject]@{ Username=$user; Password=$plain } +} + +function Read-SmtpResponse { + param( + [Parameter(Mandatory)][IO.StreamReader]$Reader + ) + + $lines = New-Object Collections.Generic.List[string] + $first = $Reader.ReadLine() + if ($null -eq $first) { throw "SMTP-Verbindung unerwartet beendet." } + $lines.Add($first) + + if ($first -match '^(\d{3})-') { + $code = $matches[1] + while ($true) { + $line = $Reader.ReadLine() + if ($null -eq $line) { throw "SMTP-Verbindung unerwartet beendet." } + $lines.Add($line) + if ($line -match "^$code ") { break } + } + } + + $status = 0 + if ($lines[0] -match '^(\d{3})') { $status = [int]$matches[1] } + + return [pscustomobject]@{ + Code = $status + Text = ($lines -join "`n") + } +} + +function Invoke-AuthIfNeeded { + param( + [IO.StreamReader]$Reader, + [IO.StreamWriter]$Writer, + $Auth + ) + + if ([string]::IsNullOrWhiteSpace([string]$Auth.Username)) { return } + + $Writer.WriteLine("AUTH LOGIN") + $r = Read-SmtpResponse -Reader $Reader + if ($r.Code -ne 334) { throw "AUTH LOGIN: $($r.Text)" } + + $Writer.WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Auth.Username))) + $r = Read-SmtpResponse -Reader $Reader + if ($r.Code -ne 334) { throw "AUTH Benutzer: $($r.Text)" } + + $Writer.WriteLine([Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Auth.Password))) + $r = Read-SmtpResponse -Reader $Reader + if ($r.Code -ne 235) { throw "AUTH Passwort: $($r.Text)" } +} + +function Open-SmtpSession { + $config = Get-Config + $port = [int]$config.Smtp.Port + + $client = New-Object Net.Sockets.TcpClient + $client.Connect("127.0.0.1", $port) + $stream = $client.GetStream() + $stream.ReadTimeout = 10000 + $reader = New-Object IO.StreamReader($stream, [Text.Encoding]::UTF8, $true, 4096, $true) + $writer = New-Object IO.StreamWriter($stream, [Text.Encoding]::ASCII, 4096, $true) + $writer.NewLine = "`r`n" + $writer.AutoFlush = $true + + $greeting = Read-SmtpResponse -Reader $reader + if ($greeting.Code -ne 220) { throw "Greeting: $($greeting.Text)" } + + $writer.WriteLine("EHLO qa.local") + $ehlo = Read-SmtpResponse -Reader $reader + if ($ehlo.Code -ne 250) { throw "EHLO: $($ehlo.Text)" } + + $auth = Get-SmtpAuthForLocalTest + Invoke-AuthIfNeeded -Reader $reader -Writer $writer -Auth $auth + + return [pscustomobject]@{ + Client = $client + Stream = $stream + Reader = $reader + Writer = $writer + Config = $config + Auth = $auth + } +} + +function Close-SmtpSession { + param($Session) + try { $Session.Writer.WriteLine("QUIT") } catch {} + try { $Session.Reader.Dispose() } catch {} + try { $Session.Writer.Dispose() } catch {} + try { $Session.Stream.Dispose() } catch {} + try { $Session.Client.Close() } catch {} + if ($Session.Auth) { $Session.Auth.Password = $null } +} + +function Send-TestMail { + param( + [switch]$StopBeforeBody, + [string]$UniqueTag = $([guid]::NewGuid().ToString("N").Substring(0,10)) + ) + + $s = Open-SmtpSession + try { + $from = [string]$s.Config.Graph.SenderMailbox + $to = Read-Host "Test-Empfänger [Standard: $from]" + if ([string]::IsNullOrWhiteSpace($to)) { $to = $from } + + $s.Writer.WriteLine("MAIL FROM:<$from>") + $r = Read-SmtpResponse $s.Reader + if ($r.Code -ne 250) { return [pscustomobject]@{ Session=$s; Response=$r; QueueId=$null; Stage="MAIL" } } + + $s.Writer.WriteLine("RCPT TO:<$to>") + $r = Read-SmtpResponse $s.Reader + if ($r.Code -ne 250) { return [pscustomobject]@{ Session=$s; Response=$r; QueueId=$null; Stage="RCPT" } } + + $s.Writer.WriteLine("DATA") + $r = Read-SmtpResponse $s.Reader + + if ($r.Code -ne 354) { + return [pscustomobject]@{ Session=$s; Response=$r; QueueId=$null; Stage="DATA" } + } + + $s.Writer.WriteLine("From: <$from>") + $s.Writer.WriteLine("To: <$to>") + $s.Writer.WriteLine("Subject: SMTPGraphRelay QA $UniqueTag") + $s.Writer.WriteLine("X-SMTPGraphRelay-QA: $UniqueTag") + $s.Writer.WriteLine("") + + if ($StopBeforeBody) { + return [pscustomobject]@{ Session=$s; Response=$r; QueueId=$null; Stage="DATA-OPEN"; Tag=$UniqueTag } + } + + $s.Writer.WriteLine("QA test $UniqueTag") + $s.Writer.WriteLine(".") + $final = Read-SmtpResponse $s.Reader + + $qid = $null + if ($final.Text -match 'queue-id=([A-Fa-f0-9]+)') { $qid = $matches[1] } + + return [pscustomobject]@{ Session=$s; Response=$final; QueueId=$qid; Stage="FINAL"; Tag=$UniqueTag } + } + catch { + Close-SmtpSession $s + throw + } +} + +function Get-QueuePaths { + $config = Get-Config + $queueRoot = Get-FullPath ([string]$config.Paths.Queue) + return [pscustomobject]@{ + Pending = Join-Path $queueRoot "pending" + Processing = Join-Path $queueRoot "processing" + Failed = Get-FullPath ([string]$config.Paths.Failed) + } +} + +function Remove-QAMessage { + param([string]$QueueId) + + if ([string]::IsNullOrWhiteSpace($QueueId)) { return } + $paths = Get-QueuePaths + + foreach ($dir in @($paths.Pending,$paths.Processing,$paths.Failed)) { + foreach ($p in @( + (Join-Path $dir "$QueueId.eml"), + (Join-Path $dir "$QueueId.eml.json") + )) { + Remove-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue + } + } +} + +function Wait-ForQueueMeta { + param( + [string]$QueueId, + [int]$TimeoutSeconds = 15 + ) + + $paths = Get-QueuePaths + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + + while ((Get-Date) -lt $deadline) { + foreach ($dir in @($paths.Pending,$paths.Processing,$paths.Failed)) { + $metaPath = Join-Path $dir "$QueueId.eml.json" + if (Test-Path $metaPath) { + try { + $meta = Get-Content $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json + return [pscustomobject]@{ Directory=$dir; Meta=$meta; Path=$metaPath } + } catch {} + } + } + Start-Sleep -Milliseconds 250 + } + + return $null +} + +function Invoke-WithTemporaryConfig { + param( + [Parameter(Mandatory)][scriptblock]$Configure, + [Parameter(Mandatory)][scriptblock]$Test + ) + + $backup = Join-Path $env:TEMP ("SMTPGraphRelay-config-qa-{0}.json" -f [guid]::NewGuid().ToString("N")) + Copy-Item $ConfigPath $backup -Force + + try { + & $Configure + Restart-Relay + & $Test + } + finally { + try { + Restore-OriginalConfig -BackupPath $backup + Restart-Relay + Write-Ok "Original-config.json wiederhergestellt." + } + catch { + Write-Fail "Config-Restore/Relay-Restart fehlgeschlagen: $($_.Exception.Message)" + } + Remove-Item $backup -Force -ErrorAction SilentlyContinue + } +} + +function Test-GraphStatus { + param([int]$StatusCode) + + Invoke-WithTemporaryConfig ` + -Configure { + Set-QADebug -SimulateGraphStatus $StatusCode + } ` + -Test { + $result = Send-TestMail + try { + if ($result.Response.Code -ne 250 -or -not $result.QueueId) { + Write-Fail "SMTP-Annahme fehlgeschlagen: $($result.Response.Text)" + return + } + + Write-Ok "SMTP hat Testmail angenommen: $($result.QueueId)" + $found = Wait-ForQueueMeta -QueueId $result.QueueId -TimeoutSeconds 15 + + if (-not $found) { + Write-Fail "Queue-Metadaten nicht gefunden." + return + } + + Start-Sleep -Seconds 1 + $found = Wait-ForQueueMeta -QueueId $result.QueueId -TimeoutSeconds 5 + + if ($found -and [int]$found.Meta.RetryCount -ge 1 -and [int]$found.Meta.LastStatusCode -eq $StatusCode) { + Write-Ok "HTTP $StatusCode korrekt als Retry behandelt (RetryCount=$($found.Meta.RetryCount))." + } + else { + Write-Fail "Erwartete Retry-Metadaten für HTTP $StatusCode fehlen." + } + } + finally { + if ($result) { + Close-SmtpSession $result.Session + Remove-QAMessage $result.QueueId + } + } + } +} + +function Test-QueueWriteFailure { + Invoke-WithTemporaryConfig ` + -Configure { + Set-QADebug -SimulateQueueWriteFailure $true + } ` + -Test { + $result = Send-TestMail + try { + if ($result.Stage -eq "FINAL" -and $result.Response.Code -eq 451) { + Write-Ok "Queue-Schreibfehler korrekt mit 451 zurückgewiesen." + } + else { + Write-Fail "Erwartet 451 nach DATA, erhalten: $($result.Response.Text)" + } + } + finally { + if ($result) { Close-SmtpSession $result.Session } + } + } +} + +function Test-PendingBackpressure { + $config = Get-Config + $limit = if ($config.Queue.PSObject.Properties.Name -contains "MaxPendingMessages") { [int]$config.Queue.MaxPendingMessages } else { 5000 } + + Invoke-WithTemporaryConfig ` + -Configure { + Set-QADebug -OverridePendingCount $limit + } ` + -Test { + $result = Send-TestMail + try { + if ($result.Stage -eq "DATA" -and $result.Response.Code -eq 452) { + Write-Ok "Pending-Queue-Limit korrekt vor DATA mit 452 blockiert." + } + else { + Write-Fail "Erwartet 452 bei DATA, erhalten: $($result.Response.Text)" + } + } + finally { + if ($result) { Close-SmtpSession $result.Session } + } + } +} + +function Test-DiskBackpressure { + Invoke-WithTemporaryConfig ` + -Configure { + Set-QADebug -OverrideFreeDiskSpaceMB 0 + } ` + -Test { + $result = Send-TestMail + try { + if ($result.Stage -eq "DATA" -and $result.Response.Code -eq 452) { + Write-Ok "Disk-Backpressure korrekt vor DATA mit 452 blockiert." + } + else { + Write-Fail "Erwartet 452 bei DATA, erhalten: $($result.Response.Text)" + } + } + finally { + if ($result) { Close-SmtpSession $result.Session } + } + } +} + +function Test-GracefulDuringData { + Invoke-WithTemporaryConfig ` + -Configure { + Set-QADebug + } ` + -Test { + $result = Send-TestMail -StopBeforeBody + try { + if ($result.Stage -ne "DATA-OPEN") { + Write-Fail "DATA-Session konnte nicht geöffnet werden." + return + } + + Write-Info "DATA ist offen. Fordere jetzt Graceful Shutdown an..." + [IO.File]::WriteAllText($ShutdownSignalPath, [DateTime]::UtcNow.ToString("o")) + + Start-Sleep -Seconds 1 + $task = Get-ScheduledTask -TaskName $TaskName + if ($task.State -eq "Running") { + Write-Ok "Relay bleibt während aktiver DATA-Session erwartungsgemäß aktiv." + } + else { + Write-Fail "Relay wurde beendet, obwohl DATA-Session noch offen war." + return + } + + $result.Session.Writer.WriteLine("QA graceful DATA test") + $result.Session.Writer.WriteLine(".") + $final = Read-SmtpResponse $result.Session.Reader + + if ($final.Code -eq 250) { + Write-Ok "Laufende DATA-Mail wurde trotz Shutdown sauber angenommen." + if ($final.Text -match 'queue-id=([A-Fa-f0-9]+)') { + $result.QueueId = $matches[1] + } + } + else { + Write-Fail "Mailabschluss während Shutdown: $($final.Text)" + } + + $deadline = (Get-Date).AddSeconds(15) + do { + Start-Sleep -Milliseconds 250 + $task = Get-ScheduledTask -TaskName $TaskName + } while ($task.State -eq "Running" -and (Get-Date) -lt $deadline) + + if ($task.State -ne "Running") { + Write-Ok "Relay hat sich nach Abschluss der Session sauber beendet." + } + else { + Write-Fail "Relay läuft nach Abschluss der Session weiter." + } + } + finally { + if ($result) { + Close-SmtpSession $result.Session + Remove-QAMessage $result.QueueId + } + Remove-Item $ShutdownSignalPath -Force -ErrorAction SilentlyContinue + } + } +} + +function Test-GracefulDuringGraph { + Invoke-WithTemporaryConfig ` + -Configure { + Set-QADebug -DelayBeforeGraphSendSeconds 8 -SimulateGraphStatus 500 + } ` + -Test { + $result = Send-TestMail + try { + if ($result.Response.Code -ne 250 -or -not $result.QueueId) { + Write-Fail "SMTP-Testmail wurde nicht angenommen." + return + } + + Close-SmtpSession $result.Session + $result.Session = $null + + $paths = Get-QueuePaths + $processing = Join-Path $paths.Processing "$($result.QueueId).eml" + $deadline = (Get-Date).AddSeconds(10) + + while (-not (Test-Path $processing) -and (Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 200 + } + + if (-not (Test-Path $processing)) { + Write-Fail "Mail erreichte processing nicht rechtzeitig." + return + } + + Write-Ok "Mail ist in processing / künstlicher Graph-Verzögerung." + [IO.File]::WriteAllText($ShutdownSignalPath, [DateTime]::UtcNow.ToString("o")) + + Start-Sleep -Seconds 1 + $task = Get-ScheduledTask -TaskName $TaskName + + if ($task.State -eq "Running") { + Write-Ok "Relay wartet während aktivem Queue-/Graph-Worker." + } + else { + Write-Fail "Relay wurde zu früh beendet." + } + + $deadline = (Get-Date).AddSeconds(20) + do { + Start-Sleep -Milliseconds 250 + $task = Get-ScheduledTask -TaskName $TaskName + } while ($task.State -eq "Running" -and (Get-Date) -lt $deadline) + + if ($task.State -ne "Running") { + Write-Ok "Relay hat Worker auslaufen lassen und sich danach beendet." + } + else { + Write-Fail "Graceful Shutdown während Queue-/Graph-Worker dauerte zu lange." + } + } + finally { + if ($result -and $result.Session) { Close-SmtpSession $result.Session } + if ($result) { Remove-QAMessage $result.QueueId } + Remove-Item $ShutdownSignalPath -Force -ErrorAction SilentlyContinue + } + } +} + +function Clear-QADebug { + $config = Get-Config + if ($config.PSObject.Properties.Name -contains "Debug") { + $config.PSObject.Properties.Remove("Debug") + Save-Config $config + Restart-Relay + Write-Ok "Debug-/QA-Block aus config.json entfernt." + } + else { + Write-Ok "Kein Debug-/QA-Block vorhanden." + } +} + +if ($PSVersionTable.PSEdition -ne "Desktop" -or $PSVersionTable.PSVersion.Major -ne 5) { + throw "Dieses QA-Script muss mit Windows PowerShell 5.1 ausgeführt werden." +} + +while ($true) { + Clear-Host + Write-Title "SMTPGraphRelay - Failure Mode QA" + + Write-Warn "Dieses Werkzeug erzeugt absichtlich Fehlerzustände." + Write-Host "Die Original-config.json wird für jeden Test gesichert und danach wiederhergestellt." + Write-Host "" + Write-Host " [1] Graph HTTP 429 / Retry testen" + Write-Host " [2] Graph HTTP 500 / Retry testen" + Write-Host " [3] Queue-Schreibfehler / SMTP 451 testen" + Write-Host " [4] Pending-Queue-Backpressure / SMTP 452 testen" + Write-Host " [5] Disk-Backpressure / SMTP 452 testen" + Write-Host " [6] Graceful Shutdown während SMTP DATA testen" + Write-Host " [7] Graceful Shutdown während Queue/Graph testen" + Write-Host " [8] QA-/Debug-Block aus Config entfernen" + Write-Host " [0] Beenden" + Write-Host "" + + $choice = Read-Host "Auswahl" + + try { + switch ($choice) { + "1" { Test-GraphStatus -StatusCode 429 } + "2" { Test-GraphStatus -StatusCode 500 } + "3" { Test-QueueWriteFailure } + "4" { Test-PendingBackpressure } + "5" { Test-DiskBackpressure } + "6" { Test-GracefulDuringData } + "7" { Test-GracefulDuringGraph } + "8" { Clear-QADebug } + "0" { break } + default { Write-Warn "Ungültige Auswahl." } + } + } + catch { + Write-Fail $_.Exception.Message + } + + if ($choice -ne "0") { + Write-Host "" + Read-Host "Enter drücken" + } + + if ($choice -eq "0") { break } +}