diff --git a/README.md b/README.md index b7bd4f8..abe2f31 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ -# SMTPGraphRelay 1.7.0 +# SMTPGraphRelay 1.8.0 -SMTPGraphRelay V1.7 ergänzt optionales lokales SMTP-AUTH für interne Clients. +V1.8 ergänzt zwei Betriebsfunktionen: -> **Hinweis:** `AUTH LOGIN` und `AUTH PLAIN` werden hier bewusst ohne TLS angeboten, -> weil dieses Relay für vertrauenswürdige interne Netze gedacht ist. Die Zugangsdaten -> sind auf dem Netzwerkpfad daher nur Base64-kodiert, nicht verschlüsselt. Die -> bestehende IP-Allowlist sollte weiterhin restriktiv gesetzt bleiben. +- Failed-Queue-Verwaltung im Setup-/Manager +- kooperativer Graceful Shutdown für Update, Repair und manuelles Stoppen über den Manager + +Alle Funktionen aus V1.7 bleiben erhalten, insbesondere SMTP AUTH LOGIN/PLAIN, +Queue-ID, Retry/Backpressure, Zertifikatsrotation, Health Check und Online-Update. ## Installation @@ -15,234 +16,141 @@ SMTPGraphRelay V1.7 ergänzt optionales lokales SMTP-AUTH für interne Clients. $u='https://me-gitea.maieredv.cloud/MAIEREDV/SMTPGraphRelay/raw/branch/main/Setup-SMTPGraphRelay.ps1';$f="$env:TEMP\Setup-SMTPGraphRelay.ps1";Invoke-WebRequest $u -UseBasicParsing -OutFile $f;& $f ``` -## Neu in V1.7: SMTP AUTH +## Failed Queue verwalten -Unterstützt: +Im Manager: ```text -AUTH LOGIN -AUTH PLAIN -``` - -EHLO bewirbt die Mechanismen, sobald mindestens ein SMTP-Benutzer existiert: - -```text -250-AUTH LOGIN PLAIN -``` - -Erfolgreiche Anmeldung: - -```text -235 2.7.0 Authentication successful -``` - -Fehlgeschlagene Anmeldung: - -```text -535 5.7.8 Authentication credentials invalid -``` - -Wenn AUTH für den Client erforderlich ist: - -```text -530 5.7.0 Authentication required -``` - -Nach standardmäßig fünf Fehlversuchen innerhalb derselben Verbindung werden -weitere AUTH-Versuche temporär abgewiesen: - -```text -454 4.7.0 Too many authentication failures -``` - -## IP-Allowlist bleibt vorgeschaltet - -SMTP-AUTH ersetzt die bestehende `AllowedNetworks`-Prüfung **nicht**. - -Ein Client muss zuerst aus einem erlaubten Netz kommen. Erst danach kann er sich -optional bzw. verpflichtend authentifizieren. - -AUTH kann daher niemals einem ansonsten nicht erlaubten Host Zugriff verschaffen. - -## Passwortspeicherung - -SMTP-Passwörter werden nicht im Klartext gespeichert. - -Verwendet wird: - -```text -PBKDF2-HMAC-SHA256 -Salt: 16 Byte zufällig -Iterations: 150000 -Hash: 32 Byte -``` - -Beispiel in `config.json`: - -```json -{ - "Username": "scanner01", - "Salt": "...", - "PasswordHash": "...", - "Iterations": 150000 -} -``` - -## Konfiguration - -```json -"Smtp": { - "RequireAuth": true, - "AuthMaxFailures": 5, - "AllowUnauthenticatedNetworks": [ - "10.60.10.0/24" - ], - "AuthUsers": [ - { - "Username": "scanner01", - "Salt": "...", - "PasswordHash": "...", - "Iterations": 150000 - } - ] -} -``` - -### RequireAuth - -```text -false -``` - -AUTH ist optional. Clients aus `AllowedNetworks` dürfen weiterhin ohne -Benutzername/Passwort senden. - -```text -true -``` - -Clients müssen sich authentifizieren, außer ihre IP liegt in -`AllowUnauthenticatedNetworks`. - -### AllowUnauthenticatedNetworks - -Beispiel: - -```json -[ - "10.60.10.0/24", - "192.168.50.10" -] -``` - -Diese Hosts/Netze dürfen trotz `RequireAuth=true` ohne AUTH senden. - -Sie müssen trotzdem zusätzlich durch `AllowedNetworks` erlaubt sein. - -## Benutzerverwaltung - -Im Setup-/Manager gibt es jetzt: - -```text -[9] SMTP-AUTH verwalten +[10] Failed Queue verwalten ``` Untermenü: ```text -[1] SMTP-AUTH erforderlich EIN/AUS -[2] Benutzer hinzufügen -[3] Benutzer anzeigen -[4] Passwort ändern -[5] Benutzer löschen -[6] Netze ohne Auth verwalten -[7] Max. Fehlversuche ändern +[1] Failed Queue anzeigen +[2] Details einer Mail anzeigen +[3] Eine Mail erneut zustellen +[4] Alle Mails erneut zustellen +[5] Eine Mail endgültig löschen +[6] Alle Failed-Mails endgültig löschen [0] Zurück ``` -Beim Hinzufügen bzw. Ändern eines Passworts erfolgt die Eingabe verdeckt. - -## Graph-Sender bleibt unabhängig - -Der SMTP-Benutzer dient ausschließlich zur Authentifizierung am lokalen Relay. - -Beispiel: - -```text -SMTP AUTH: scanner01 -MAIL FROM: scanner@device.local - | - v - SMTPGraphRelay - | - ForceSender=true - | - v - info@example.com -``` - -AUTH verändert weder die Entra-App noch das Graph-Zertifikat oder -`Graph.SenderMailbox`. - -## Queue-Metadaten - -Bei authentifizierten Einlieferungen wird der Benutzer zusätzlich in den -Queue-Metadaten gespeichert: - -```json -"AuthenticatedUser": "scanner01" -``` - -und im Log ausgegeben: - -```text -[7F3A91C2D441] Mail angenommen | ... | Auth=scanner01 -``` - -Bei nicht authentifizierten, aber erlaubten Verbindungen: - -```text -Auth=unauthenticated -``` - -## Health Check - -Der Health Check validiert zusätzlich die SMTP-AUTH-Konfiguration. - -Eine Testmail kann weiterhin mit: - -```powershell -.\Test-SMTPGraphRelay.ps1 -SendTestMail -TestRecipient "user@example.com" -``` - -ausgeführt werden. - -Ist AUTH erforderlich, fragt der Health Check bei Bedarf Benutzername und -Passwort interaktiv ab. - -Optional: - -```powershell -.\Test-SMTPGraphRelay.ps1 ` - -SendTestMail ` - -TestRecipient "user@example.com" ` - -SmtpUsername "scanner01" -``` - -Das Passwort wird weiterhin verdeckt abgefragt. - -## V1.6 Funktionen bleiben erhalten +Die Übersicht zeigt u. a.: - Queue-ID -- Received-Header -- automatische Message-ID -- MaxRecipients -- MaxMessagesPerConnection -- MaxPendingMessages -- MinFreeDiskSpaceMB -- Queue-/Disk-Backpressure -- parallele SMTP-Clients -- Graph-Retry -- Log-Rotation -- Zertifikatsüberwachung -- Online-Update / Repair / Rollback +- Zeitpunkt +- RetryCount +- Envelope-From +- Empfänger +- letzten HTTP-/Graph-Status +- Größe + +In den Details steht zusätzlich die letzte Fehlermeldung und – falls vorhanden – +der SMTP-AUTH-Benutzer. + +### Requeue + +Beim erneuten Zustellen wird die `.eml` samt Metadaten von `failed` nach +`queue\pending` verschoben. + +Dabei werden: + +```text +RetryCount = 0 +NextAttemptUtc = jetzt +RequeuedUtc = jetzt +``` + +gesetzt. Die alte Fehlerbeschreibung bleibt in den Metadaten erhalten, bis ein +neuer Versandversuch sie aktualisiert. + +Der laufende Queue-Worker nimmt die Mail anschließend automatisch wieder auf. + +## Graceful Shutdown + +Update und Repair verwenden nicht mehr zuerst `Stop-ScheduledTask`. + +Stattdessen erstellt der Manager: + +```text +C:\Program Files\SMTPGraphRelay\shutdown.request +``` + +Der Relay erkennt das Signal kurzfristig und führt diesen Ablauf aus: + +```text +Shutdown angefordert + | + v +SMTP-Listener schließen + | + +--> keine neuen Verbindungen mehr + | + v +laufende SMTP-Sessions auslaufen lassen + | + v +Queue-/Graph-Worker beendet aktuellen Versand + | + +--> startet keine weitere Queue-Mail + | + v +Runspaces schließen + | + v +Graph-Verbindung trennen + | + v +Relay beendet sich selbst +``` + +Standard-Timeout: + +```json +"Smtp": { + "GracefulShutdownSeconds": 30 +} +``` + +Zulässiger Bereich: + +```text +5 bis 300 Sekunden +``` + +Wenn der Relay innerhalb dieses Zeitraums nicht selbst beendet ist, verwendet +der Manager als Fallback einen harten `Stop-ScheduledTask`. + +Beim nächsten Start wird ein eventuell altes `shutdown.request` vorsorglich +entfernt. + +## Warum das für Updates wichtig ist + +Ein Update/Repair kann damit nicht mehr unnötig mitten in: + +- einer SMTP-DATA-Übertragung +- einer aktiven SMTP-Session +- oder einem laufenden Graph-sendMail-Aufruf + +den Prozess beenden. + +Wenn ein Client oder Graph länger als das konfigurierte Grace-Timeout hängt, +wird nach Ablauf des Timeouts kontrolliert auf den bisherigen harten Stop +zurückgefallen. + +## Manager + +```text +[1] Neuinstallation aus Gitea +[2] Installation aus Gitea reparieren +[3] Nach Online-Updates suchen +[4] Entra / Exchange RBAC prüfen +[5] Zertifikat erneuern +[6] Health Check ausführen +[7] Deinstallieren +[8] Status anzeigen +[9] SMTP-AUTH verwalten +[10] Failed Queue verwalten +[0] Beenden +``` diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 index 16a7312..1b0290d 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.7: SMTP AUTH LOGIN/PLAIN mit PBKDF2-SHA256, Benutzer-/Session-Schutz und V1.6 Queue-/Backpressure-Funktionen + V1.8: Failed-Queue-Verwaltung und kooperativer Graceful Shutdown; inklusive SMTP AUTH und V1.6 Queue-/Backpressure-Funktionen #> [CmdletBinding()] @@ -697,6 +697,12 @@ function Process-Queue { $dirs = Get-QueueDirectories foreach ($file in Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File | Sort-Object CreationTimeUtc) { + if ($script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath" -and + (Test-Path -LiteralPath ([string]$script:Config.ShutdownSignalPath))) { + Write-Log "Queue-Worker: Shutdown angefordert; starte keine weitere Queue-Mail." + break + } + $metaPath = "$($file.FullName).json" if (Test-Path -LiteralPath $metaPath) { @@ -1733,6 +1739,10 @@ function New-WorkerConfig { $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 } @@ -1827,6 +1837,22 @@ if (-not (Test-Path -LiteralPath $ConfigPath)) { $script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json +$script:InstallRoot = Split-Path -Parent ([IO.Path]::GetFullPath($ConfigPath)) +$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 {} +} + +# Ein altes Signal darf einen frisch gestarteten Relay nicht sofort wieder beenden. +Remove-Item -LiteralPath $script:ShutdownSignalPath -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path (Resolve-PathFromConfig $script:Config.Paths.Logs) -Force | Out-Null Initialize-QueueDirectories @@ -1946,6 +1972,12 @@ $queueBootstrap try { while (`$true) { + if (`$script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath" -and + (Test-Path -LiteralPath ([string]`$script:Config.ShutdownSignalPath))) { + Write-Log "Queue-Worker: Shutdown-Signal erkannt." + break + } + try { Process-Queue } @@ -1953,7 +1985,15 @@ try { Write-Log ("Queue-Worker: {0}" -f `$_.Exception.Message) "ERROR" } - Start-Sleep -Seconds ([int]`$script:Config.Queue.PollSeconds) + # In kleinen Schritten schlafen, damit Shutdown nicht erst nach PollSeconds reagiert. + `$sleepUntil = (Get-Date).AddSeconds([int]`$script:Config.Queue.PollSeconds) + while ((Get-Date) -lt `$sleepUntil) { + if (`$script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath" -and + (Test-Path -LiteralPath ([string]`$script:Config.ShutdownSignalPath))) { + break + } + Start-Sleep -Milliseconds 250 + } } } finally { @@ -1973,7 +2013,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.7 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" +Write-Log "SMTPGraphRelay V1.8 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." @@ -1989,6 +2029,9 @@ $authMode = if ($authSettings.RequireAuth) { "erforderlich" } else { "optional/d Write-Log ("SMTP-AUTH: {0}; {1} Benutzer; max. {2} Fehlversuche/Verbindung." -f ` $authMode, @($authSettings.Users).Count, $authSettings.AuthMaxFailures) +Write-Log ("Graceful Shutdown: bis zu {0} Sekunden für aktive Sessions/Worker." -f ` + $script:GracefulShutdownSeconds) + $logSettings = Get-LogSettings Write-Log ("Log-Rotation: max. {0} MB pro Datei, Aufbewahrung {1} Tage." -f ` $logSettings.MaxFileSizeMB, $logSettings.RetentionDays) @@ -1997,14 +2040,24 @@ 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) } - # Sollte der Queue-Worker unerwartet beendet werden, Relay nicht still - # ohne Versand weiterlaufen lassen. + # Sollte der Queue-Worker unerwartet beendet werden, ist das nur dann ein + # Fehler, wenn gerade KEIN geplanter Shutdown läuft. if ($script:QueueAsyncResult.IsCompleted) { + if (Test-Path -LiteralPath $script:ShutdownSignalPath) { + break + } + try { [void]$script:QueuePowerShell.EndInvoke($script:QueueAsyncResult) throw "Queue-Worker wurde unerwartet beendet." @@ -2024,34 +2077,63 @@ try { } } finally { - Write-Log "SMTPGraphRelay wird beendet..." "INFO" + Write-Log "SMTPGraphRelay fährt kontrolliert herunter..." 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 {} + $deadline = (Get-Date).AddSeconds($script:GracefulShutdownSeconds) + + # Laufende SMTP-Sessions bekommen Zeit, ihre aktuelle Nachricht/Session sauber + # abzuschließen. Es werden bereits keine neuen Verbindungen mehr angenommen. + 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() } - $script:ActiveSmtpWorkers.Clear() try { $script:SmtpRunspacePool.Close() } catch {} try { $script:SmtpRunspacePool.Dispose() } catch {} - try { $script:QueuePowerShell.Stop() } catch {} + # Queue-Worker darf den aktuell laufenden Graph-Aufruf beenden. Durch das + # Shutdown-Signal startet Process-Queue anschließend keine weitere Mail. + 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 { $script:QueuePowerShell.Dispose() } catch {} try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } catch {} - Write-Log "SMTPGraphRelay beendet." + + Remove-Item -LiteralPath $script:ShutdownSignalPath -Force -ErrorAction SilentlyContinue + Write-Log "SMTPGraphRelay sauber beendet." } diff --git a/Setup-SMTPGraphRelay.ps1 b/Setup-SMTPGraphRelay.ps1 index f4e843e..1939d35 100644 --- a/Setup-SMTPGraphRelay.ps1 +++ b/Setup-SMTPGraphRelay.ps1 @@ -440,15 +440,60 @@ function Ensure-ScheduledTask { function Stop-RelayTask { $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue - if ($task -and $task.State -eq "Running") { + + if (-not $task -or $task.State -ne "Running") { + return + } + + $graceSeconds = 30 + + try { + $config = Get-RelayConfig -TargetPath $InstallPath + if ($config -and $config.Smtp.PSObject.Properties.Name -contains "GracefulShutdownSeconds") { + $configured = [int]$config.Smtp.GracefulShutdownSeconds + if ($configured -ge 5 -and $configured -le 300) { + $graceSeconds = $configured + } + } + } + catch {} + + $signalPath = Join-Path $InstallPath "shutdown.request" + + try { + [IO.File]::WriteAllText( + $signalPath, + ([DateTime]::UtcNow.ToString("o")), + (New-Object Text.UTF8Encoding($false)) + ) + + Write-Info "Graceful Shutdown angefordert. Warte auf Relay (max. $graceSeconds Sekunden)..." + + $deadline = (Get-Date).AddSeconds($graceSeconds + 5) + + while ((Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 250 + $current = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + + if (-not $current -or $current.State -ne "Running") { + Write-Ok "Relay sauber beendet." + return + } + } + + Write-Warn "Graceful-Shutdown-Timeout erreicht. Task wird hart beendet." Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue Start-Sleep -Milliseconds 750 } + finally { + Remove-Item -LiteralPath $signalPath -Force -ErrorAction SilentlyContinue + } } function Start-RelayTask { $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if ($task) { + Remove-Item -LiteralPath (Join-Path $InstallPath "shutdown.request") -Force -ErrorAction SilentlyContinue Start-ScheduledTask -TaskName $TaskName Start-Sleep -Seconds 2 Write-Ok "Scheduled Task gestartet." @@ -879,6 +924,7 @@ function Install-New { AuthMaxFailures = 5 AllowUnauthenticatedNetworks = @() AuthUsers = @() + GracefulShutdownSeconds = 30 } Graph = [ordered]@{ TenantId = $tenantId @@ -1483,6 +1529,308 @@ function Save-SmtpAuthConfigAndRestart { Start-RelayTask } + +function Get-FailedQueuePath { + param([Parameter(Mandatory)]$Config) + + $path = [string]$Config.Paths.Failed + + if ([IO.Path]::IsPathRooted($path)) { + return $path + } + + return (Join-Path $InstallPath $path) +} + +function Get-PendingQueuePath { + param([Parameter(Mandatory)]$Config) + + $path = [string]$Config.Paths.Queue + + if (-not [IO.Path]::IsPathRooted($path)) { + $path = Join-Path $InstallPath $path + } + + return (Join-Path $path "pending") +} + +function Get-FailedQueueEntries { + param([Parameter(Mandatory)]$Config) + + $failedPath = Get-FailedQueuePath -Config $Config + + if (-not (Test-Path -LiteralPath $failedPath)) { + return @() + } + + $entries = @() + + foreach ($file in Get-ChildItem -LiteralPath $failedPath -Filter "*.eml" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime) { + $metaPath = "$($file.FullName).json" + $meta = $null + + if (Test-Path -LiteralPath $metaPath) { + try { + $meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json + } + catch {} + } + + $queueId = [IO.Path]::GetFileNameWithoutExtension($file.Name) + if ($meta -and $meta.PSObject.Properties.Name -contains "QueueId" -and $meta.QueueId) { + $queueId = [string]$meta.QueueId + } + + $entries += [pscustomobject]@{ + QueueId = $queueId + FileName = $file.Name + Path = $file.FullName + MetaPath = $metaPath + SizeKB = [Math]::Round($file.Length / 1KB, 1) + FailedSince = $file.LastWriteTime + RetryCount = if ($meta -and $meta.RetryCount -ne $null) { [int]$meta.RetryCount } else { $null } + From = if ($meta) { [string]$meta.EnvelopeFrom } else { "" } + Recipients = if ($meta) { (@($meta.EnvelopeRecipients) -join ", ") } else { "" } + LastStatusCode = if ($meta) { [string]$meta.LastStatusCode } else { "" } + LastError = if ($meta) { [string]$meta.LastError } else { "" } + AuthenticatedUser = if ($meta -and $meta.PSObject.Properties.Name -contains "AuthenticatedUser") { [string]$meta.AuthenticatedUser } else { "" } + Meta = $meta + } + } + + return @($entries) +} + +function Retry-FailedQueueEntry { + param( + [Parameter(Mandatory)]$Config, + [Parameter(Mandatory)]$Entry + ) + + $pendingPath = Get-PendingQueuePath -Config $Config + New-Item -ItemType Directory -Path $pendingPath -Force | Out-Null + + $targetEml = Join-Path $pendingPath $Entry.FileName + $targetMeta = "$targetEml.json" + + if (Test-Path -LiteralPath $targetEml) { + throw "Pending enthält bereits '$($Entry.FileName)'." + } + + if ($Entry.Meta) { + $meta = $Entry.Meta + + if ($meta.PSObject.Properties.Name -contains "RetryCount") { + $meta.RetryCount = 0 + } else { + $meta | Add-Member -NotePropertyName RetryCount -NotePropertyValue 0 + } + + $now = [DateTime]::UtcNow.ToString("o") + + if ($meta.PSObject.Properties.Name -contains "NextAttemptUtc") { + $meta.NextAttemptUtc = $now + } else { + $meta | Add-Member -NotePropertyName NextAttemptUtc -NotePropertyValue $now + } + + if ($meta.PSObject.Properties.Name -contains "RequeuedUtc") { + $meta.RequeuedUtc = $now + } else { + $meta | Add-Member -NotePropertyName RequeuedUtc -NotePropertyValue $now + } + + $meta | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $Entry.MetaPath -Encoding UTF8 + } + + if (Test-Path -LiteralPath $Entry.MetaPath) { + Move-Item -LiteralPath $Entry.MetaPath -Destination $targetMeta -Force + } + + Move-Item -LiteralPath $Entry.Path -Destination $targetEml -Force +} + +function Remove-FailedQueueEntry { + param([Parameter(Mandatory)]$Entry) + + Remove-Item -LiteralPath $Entry.Path -Force -ErrorAction Stop + Remove-Item -LiteralPath $Entry.MetaPath -Force -ErrorAction SilentlyContinue +} + +function Manage-FailedQueue { + $config = Get-RelayConfig -TargetPath $InstallPath + + if (-not $config) { + Write-Fail "config.json nicht gefunden." + return + } + + while ($true) { + Clear-Host + Write-Title "SMTPGraphRelay - Failed Queue" + + $entries = @(Get-FailedQueueEntries -Config $config) + + Write-Host "Failed-Mails: $($entries.Count)" + Write-Host "" + + Write-Host " [1] Failed Queue anzeigen" + Write-Host " [2] Details einer Mail anzeigen" + Write-Host " [3] Eine Mail erneut zustellen" + Write-Host " [4] Alle Mails erneut zustellen" + Write-Host " [5] Eine Mail endgültig löschen" + Write-Host " [6] Alle Failed-Mails endgültig löschen" + Write-Host " [0] Zurück" + Write-Host "" + + $choice = Read-Host "Auswahl" + + switch ($choice) { + "1" { + if ($entries.Count -eq 0) { + Write-Ok "Failed Queue ist leer." + } + else { + $entries | + Select-Object QueueId,FailedSince,RetryCount,From,Recipients,LastStatusCode,SizeKB | + Format-Table -AutoSize + } + + Read-Host "Enter" + } + + "2" { + $id = Read-Host "Queue-ID" + + $entry = $entries | + Where-Object { $_.QueueId -eq $id -or $_.FileName -eq $id -or $_.FileName -eq "$id.eml" } | + Select-Object -First 1 + + if (-not $entry) { + Write-Fail "Queue-ID '$id' nicht gefunden." + } + else { + Write-Host "" + Write-Host "Queue-ID: $($entry.QueueId)" + Write-Host "Datei: $($entry.FileName)" + Write-Host "Fehlgeschlagen:$($entry.FailedSince)" + Write-Host "RetryCount: $($entry.RetryCount)" + Write-Host "Von: $($entry.From)" + Write-Host "An: $($entry.Recipients)" + Write-Host "Auth-User: $($entry.AuthenticatedUser)" + Write-Host "Status: $($entry.LastStatusCode)" + Write-Host "Größe: $($entry.SizeKB) KB" + Write-Host "" + Write-Host "Letzter Fehler:" -ForegroundColor Yellow + Write-Host $entry.LastError + } + + Read-Host "Enter" + } + + "3" { + $id = Read-Host "Queue-ID" + + $entry = $entries | + Where-Object { $_.QueueId -eq $id -or $_.FileName -eq $id -or $_.FileName -eq "$id.eml" } | + Select-Object -First 1 + + if (-not $entry) { + Write-Fail "Queue-ID '$id' nicht gefunden." + } + else { + Retry-FailedQueueEntry -Config $config -Entry $entry + Write-Ok "[$($entry.QueueId)] zurück nach pending verschoben." + } + + Read-Host "Enter" + } + + "4" { + if ($entries.Count -eq 0) { + Write-Ok "Failed Queue ist leer." + } + elseif (Confirm-Yes "Alle $($entries.Count) Failed-Mails erneut zustellen?") { + $ok = 0 + $failed = 0 + + foreach ($entry in $entries) { + try { + Retry-FailedQueueEntry -Config $config -Entry $entry + $ok++ + } + catch { + $failed++ + Write-Warn "[$($entry.QueueId)] konnte nicht requeued werden: $($_.Exception.Message)" + } + } + + Write-Ok "$ok Mail(s) zurück nach pending verschoben." + if ($failed -gt 0) { + Write-Warn "$failed Mail(s) konnten nicht verschoben werden." + } + } + + Read-Host "Enter" + } + + "5" { + $id = Read-Host "Queue-ID" + + $entry = $entries | + Where-Object { $_.QueueId -eq $id -or $_.FileName -eq $id -or $_.FileName -eq "$id.eml" } | + Select-Object -First 1 + + if (-not $entry) { + Write-Fail "Queue-ID '$id' nicht gefunden." + } + elseif (Confirm-Yes "[$($entry.QueueId)] endgültig aus Failed löschen?") { + Remove-FailedQueueEntry -Entry $entry + Write-Ok "[$($entry.QueueId)] gelöscht." + } + + Read-Host "Enter" + } + + "6" { + if ($entries.Count -eq 0) { + Write-Ok "Failed Queue ist leer." + } + elseif (Confirm-Yes "WIRKLICH alle $($entries.Count) Failed-Mails endgültig löschen?") { + if (Confirm-Yes "Endgültiges Löschen nochmals bestätigen?") { + $ok = 0 + + foreach ($entry in $entries) { + try { + Remove-FailedQueueEntry -Entry $entry + $ok++ + } + catch { + Write-Warn "[$($entry.QueueId)] konnte nicht gelöscht werden." + } + } + + Write-Ok "$ok Failed-Mail(s) endgültig gelöscht." + } + } + + Read-Host "Enter" + } + + "0" { + return + } + + default { + Write-Warn "Ungültige Auswahl." + Start-Sleep -Seconds 1 + } + } + + $config = Get-RelayConfig -TargetPath $InstallPath + } +} + function Manage-SmtpAuth { $config = Get-RelayConfig -TargetPath $InstallPath @@ -1850,6 +2198,11 @@ function Show-Status { $authUsers = if ($config.Smtp.PSObject.Properties.Name -contains "AuthUsers") { @($config.Smtp.AuthUsers).Count } else { 0 } Write-Host " AUTH: $authText ($authUsers Benutzer)" } + + try { + $failedCount = @(Get-FailedQueueEntries -Config $config).Count + Write-Host " Failed: $failedCount Mail(s)" + } catch {} } catch {} } else { @@ -1887,6 +2240,7 @@ function Show-Menu { Write-Host " [7] Deinstallieren" Write-Host " [8] Status anzeigen" Write-Host " [9] SMTP-AUTH verwalten" + Write-Host " [10] Failed Queue verwalten" Write-Host " [0] Beenden" Write-Host "" } @@ -1908,6 +2262,7 @@ while ($true) { "7" { Uninstall-Relay } "8" { Show-Status } "9" { Manage-SmtpAuth } + "10" { Manage-FailedQueue } "0" { break } default { Write-Warn "Ungültige Auswahl." } } diff --git a/version.json b/version.json index a231417..8f48eaf 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "Version": "1.7.0", + "Version": "1.8.0", "Product": "SMTPGraphRelay", "MinimumPowerShell": "5.1", "ReleaseDate": "2026-08-14",