diff --git a/README.md b/README.md index 8698f62..ebf9619 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,133 @@ -# SMTPGraphRelay V1.2 – Queue & Retry +# SMTPGraphRelay V1.3 – parallele SMTP-Verbindungen -Diese Version ersetzt nur `SMTPGraphRelay.ps1`. Die bestehende `config.json`, Entra-App, -das Zertifikat und Exchange Application RBAC bleiben unverändert. +V1.3 baut auf V1.2 auf. Entra-App, Zertifikat, Exchange Application RBAC und +`config.json` können unverändert weiterverwendet werden. -## Neue Queue-Struktur +## Neu -```text -queue\ -├── incoming\ -├── pending\ -└── processing\ +- bis zu 20 parallele SMTP-Verbindungen standardmäßig +- konfigurierbares Parallel-Limit über `Smtp.MaxConcurrentClients` +- SMTP-Clients laufen in einem .NET Runspace Pool +- Queue-/Graph-Versand läuft in einem eigenen Runspace +- ein langsamer Graph-Aufruf blockiert die SMTP-Annahme nicht mehr +- ein langsamer/hängender SMTP-Client blockiert andere Clients nicht mehr +- bei erreichtem Parallel-Limit erhält ein neuer Client: + `421 4.3.2 SMTPGraphRelay busy, try again later` +- Logging ist mit einem benannten Mutex gegen parallele Schreibzugriffe geschützt +- Queue-/Retry-Logik aus V1.2 bleibt erhalten -failed\ +## Optional: Parallel-Limit konfigurieren + +Bestehende `config.json` muss nicht geändert werden. Ohne Eintrag gilt `20`. + +Optional: + +```json +"Smtp": { + "ListenAddress": "0.0.0.0", + "Port": 2525, + "Hostname": "SMTPRELAY01", + "AllowedNetworks": [ + "127.0.0.1/32", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16" + ], + "MaxMessageSizeMB": 25, + "ClientTimeoutSeconds": 120, + "MaxConcurrentClients": 20 +} ``` -- `incoming`: Mail wird gerade atomar geschrieben. -- `pending`: vollständig angenommene und sendbare Mail. -- `processing`: aktuell durch den Worker bearbeitet. -- `failed`: permanent fehlgeschlagene oder nach MaxRetries aufgegebene Mail. +Erlaubter Bereich: 1 bis 200. Ungültige Werte fallen auf 20 zurück. -Beim Start werden alte V1-Mails direkt aus `queue\` nach `pending\` migriert. -Mails, die nach einem Absturz noch in `processing\` liegen, werden nach `pending\` -zurückgestellt. - -## Retry - -- HTTP 429: `Retry-After` wird verwendet; fehlt es, exponentielles Backoff. -- HTTP 408 und 5xx: Retry nach `RetryMinutes` aus `config.json`. -- Netzwerk-/Transportfehler ohne HTTP-Code: Retry. -- typische permanente 4xx wie 400/401/403/404/413/415/422: direkt nach `failed`. -- nach `MaxRetries`: nach `failed`. - -## Installation über bestehende Version - -1. Scheduled Task stoppen: +## Update ```powershell Stop-ScheduledTask -TaskName "SMTPGraphRelay" + +Copy-Item ` + "C:\Program Files\SMTPGraphRelay\SMTPGraphRelay.ps1" ` + "C:\Program Files\SMTPGraphRelay\SMTPGraphRelay-V1.2-backup.ps1" ``` -2. Bestehendes `SMTPGraphRelay.ps1` sichern. -3. `SMTPGraphRelay-V1.2.ps1` als `SMTPGraphRelay.ps1` in den Programmordner kopieren. -4. Task starten: +Dann `SMTPGraphRelay-V1.3.ps1` als + +```text +C:\Program Files\SMTPGraphRelay\SMTPGraphRelay.ps1 +``` + +ablegen und starten: ```powershell Start-ScheduledTask -TaskName "SMTPGraphRelay" ``` -5. Log prüfen: +Log: ```powershell -Get-Content "C:\Program Files\SMTPGraphRelay\logs\SMTPGraphRelay.log" -Tail 100 +Get-Content ` + "C:\Program Files\SMTPGraphRelay\logs\SMTPGraphRelay.log" ` + -Tail 100 ``` -## Hinweis zu 202 Accepted +Beim Start sollten u. a. erscheinen: -Microsoft Graph `sendMail` liefert bei erfolgreicher Annahme `202 Accepted`. Das bedeutet, -dass Graph die Nachricht angenommen hat, aber nicht, dass die endgültige Zustellung bereits -abgeschlossen ist. +```text +SMTPGraphRelay V1.3 gestartet ... +Maximale parallele SMTP-Verbindungen: 20 +Queue-/Graph-Worker läuft separat vom SMTP-Listener. +``` -Eine absolut garantierte Exactly-Once-Zustellung kann ein SMTP→Graph-Gateway nicht -sicherstellen: Falls Graph die Mail bereits angenommen hat und der lokale Prozess exakt -vor dem Löschen der Queue-Datei abstürzt, kann ein erneuter Versuch theoretisch ein Duplikat -erzeugen. V1.2 reduziert dieses Risiko durch die Processing-Queue, kann es aber nicht -vollständig eliminieren. +## Test für Parallelität + +In mehreren PowerShell-Fenstern gleichzeitig: + +```powershell +Send-MailMessage ` + -SmtpServer 127.0.0.1 ` + -Port 2525 ` + -From "info@maieredv.de" ` + -To "manuel.maier@maieredv.de" ` + -Subject "Parallel-Test" ` + -Body "SMTPGraphRelay V1.3" +``` + +Für einen härteren Test können mehrere Jobs gleichzeitig gestartet werden: + +```powershell +1..10 | ForEach-Object { + Start-Job -ArgumentList $_ -ScriptBlock { + param($n) + Send-MailMessage ` + -SmtpServer 127.0.0.1 ` + -Port 2525 ` + -From "info@maieredv.de" ` + -To "manuel.maier@maieredv.de" ` + -Subject "Parallel-Test $n" ` + -Body "Nachricht $n" + } +} + +Get-Job | Wait-Job | Receive-Job +Get-Job | Remove-Job +``` + +## Architektur + +```text + +--> SMTP Worker 1 --+ +SMTP Listener -----------+--> SMTP Worker 2 --+--> pending\ + +--> SMTP Worker N --+ + Runspace Pool + | + | unabhängig + v + Queue / Graph Worker + | + v + Microsoft Graph +``` + +Der SMTP-Listener nimmt dadurch weiter neue Verbindungen an, während andere Clients +noch `DATA` übertragen oder Microsoft Graph gerade langsam antwortet. diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 index 49ed907..ba62ee0 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.2: robuste Queue, statuscodeabhängiger Graph-Retry, EHLO/HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT + V1.3: parallele SMTP-Clients, separater Queue-Worker, robuste Queue, statuscodeabhängiger Graph-Retry #> [CmdletBinding()] @@ -33,7 +33,24 @@ function Write-Log { $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 - Add-Content -LiteralPath (Join-Path $logDir "SMTPGraphRelay.log") -Value $line -Encoding UTF8 + $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 {} } @@ -785,6 +802,120 @@ function Handle-SmtpClient { } } + +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." } @@ -794,33 +925,171 @@ $script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | Conv New-Item -ItemType Directory -Path (Resolve-PathFromConfig $script:Config.Paths.Logs) -Force | Out-Null Initialize-QueueDirectories +# 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 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" +Write-Log "SMTPGraphRelay V1.3 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" Write-Log "Graph-Absender: $($script:Config.Graph.SenderMailbox)" - -$lastQueueRun = [DateTime]::MinValue +Write-Log "Maximale parallele SMTP-Verbindungen: $script:MaxConcurrentClients" +Write-Log "Queue-/Graph-Worker läuft separat vom SMTP-Listener." try { while ($true) { - if ((Get-Date) -gt $lastQueueRun.AddSeconds([int]$script:Config.Queue.PollSeconds)) { - try { Process-Queue } catch { Write-Log "Queue-Worker: $($_.Exception.Message)" "ERROR" } - $lastQueueRun = Get-Date + Remove-CompletedSmtpWorkers + + # 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() - # V1 verarbeitet Clients seriell. Für typische Geräte-/Monitoring-Relays bewusst simpel. - Handle-SmtpClient -Client $client - } else { - Start-Sleep -Milliseconds 200 + Start-SmtpClientWorker -Client $client + } + else { + Start-Sleep -Milliseconds 100 } } } finally { - $listener.Stop() - Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + 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." }