From e4ceecad24c1d48ddf7870e6314082a7105a99f5 Mon Sep 17 00:00:00 2001 From: "manuel.maier" Date: Thu, 13 Aug 2026 22:57:09 +0200 Subject: [PATCH] Dateien nach "/" hochladen --- README.md | 263 +++++---------------------- SMTPGraphRelay.ps1 | 442 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 441 insertions(+), 264 deletions(-) diff --git a/README.md b/README.md index 81f8f2e..8698f62 100644 --- a/README.md +++ b/README.md @@ -1,247 +1,66 @@ -# SMTPGraphRelay V1 +# SMTPGraphRelay V1.2 – Queue & Retry -Ein kleiner nativer Windows-/PowerShell-Relay: +Diese Version ersetzt nur `SMTPGraphRelay.ps1`. Die bestehende `config.json`, Entra-App, +das Zertifikat und Exchange Application RBAC bleiben unverändert. + +## Neue Queue-Struktur ```text -Drucker / NAS / Server / Monitoring - | - | SMTP (lokal) - v - SMTPGraphRelay - | - | HTTPS / OAuth2 / Microsoft Graph - v - Microsoft 365 +queue\ +├── incoming\ +├── pending\ +└── processing\ + +failed\ ``` -## Was V1 kann +- `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. -- SMTP-Listener auf konfigurierbarer IP / Port -- `EHLO`, `HELO`, `MAIL FROM`, mehrere `RCPT TO`, `DATA`, `RSET`, `NOOP`, `QUIT` -- IPv4-Allowlist per CIDR -- maximale Mailgröße -- lokale Store-and-Forward Queue -- Retry bei Graph-/Netzwerkfehlern -- Failed-Queue nach Max-Retries -- Microsoft Graph `sendMail` -- MIME-Mail wird als MIME an Graph weitergegeben -- App-only OAuth mit Zertifikat -- First-Run erzeugt: - - selbstsigniertes Zertifikat in `LocalMachine\My` - - Entra ID App Registration - - Service Principal - - Microsoft Graph `Mail.Send` Application Permission - - Admin Consent - - `config.json` - - Windows-Firewallregel - - Scheduled Task als `SYSTEM` +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. -## Voraussetzungen +## Retry -- Windows 10/11 oder Windows Server -- Windows PowerShell 5.1 -- PowerShell als Administrator -- Internetzugang zu Microsoft Graph / Entra -- M365-/Entra-Admin, der App-Registrierungen und App Permissions vergeben darf -- bestehendes Exchange-Online-Postfach für den konfigurierten Absender +- 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 +## Installation über bestehende Version -1. ZIP entpacken, z. B.: - -```powershell -C:\Program Files\SMTPGraphRelay -``` - -2. Windows PowerShell **als Administrator** öffnen. - -3. Setup starten: - -```powershell -Set-ExecutionPolicy -Scope Process Bypass -cd "C:\Program Files\SMTPGraphRelay" -.\Setup-SMTPGraphRelay.ps1 -``` - -Das Setup fragt u. a.: - -- App-Name -- M365-Absenderpostfach -- Listen-IP -- SMTP-Port -- erlaubte Quellnetze - -Danach wird die Entra-App automatisch erstellt. - -## Standard - -Port: - -```text -2525/TCP -``` - -Default-Allowlist: - -```text -127.0.0.1/32 -10.0.0.0/8 -172.16.0.0/12 -192.168.0.0/16 -``` - -Der Absender wird standardmäßig immer auf das konfigurierte M365-Postfach umgeschrieben: - -```json -"ForceSender": true -``` - -Das verhindert, dass ein internes Gerät beliebige `From:`-Adressen durchreichen kann. - -## Start / Stop - -Start: - -```powershell -Start-ScheduledTask -TaskName "SMTPGraphRelay" -``` - -Stop: +1. Scheduled Task stoppen: ```powershell Stop-ScheduledTask -TaskName "SMTPGraphRelay" ``` -Status: +2. Bestehendes `SMTPGraphRelay.ps1` sichern. +3. `SMTPGraphRelay-V1.2.ps1` als `SMTPGraphRelay.ps1` in den Programmordner kopieren. +4. Task starten: ```powershell -Get-ScheduledTask -TaskName "SMTPGraphRelay" | Get-ScheduledTaskInfo +Start-ScheduledTask -TaskName "SMTPGraphRelay" ``` -## Test vom Relay-PC - -Wenn `Send-MailMessage` noch vorhanden ist: +5. Log prüfen: ```powershell -Send-MailMessage ` - -SmtpServer 127.0.0.1 ` - -Port 2525 ` - -From "test@local.invalid" ` - -To "dein.name@example.com" ` - -Subject "SMTPGraphRelay Test" ` - -Body "Hallo aus SMTPGraphRelay" +Get-Content "C:\Program Files\SMTPGraphRelay\logs\SMTPGraphRelay.log" -Tail 100 ``` -Alternativ kann jedes SMTP-Testtool verwendet werden. +## Hinweis zu 202 Accepted -## Verzeichnisse +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\ -├── SMTPGraphRelay.ps1 -├── Setup-SMTPGraphRelay.ps1 -├── config.json -├── config.example.json -├── queue\ -├── failed\ -└── logs\ - └── SMTPGraphRelay.log -``` - -## Queue - -Nach vollständigem SMTP-`DATA` wird die Nachricht zunächst lokal gespeichert. - -Erst **danach** bekommt der SMTP-Client: - -```text -250 2.0.0 Queued -``` - -Der Queue-Worker sendet die Nachricht anschließend über Microsoft Graph. - -Standard-Retry: - -```text -1 min -5 min -15 min -30 min -60 min -120 min -240 min -480 min -``` - -Nach acht Fehlern wandert die Mail nach `failed`. - -## Sicherheit - -### Kein offenes Relay - -Die SMTP-Seite besitzt eine IP-Allowlist. Bitte die Standard-RFC1918-Netze auf die tatsächlich benötigten Subnetze reduzieren. - -Beispiel: - -```json -"AllowedNetworks": [ - "10.60.10.0/24", - "10.20.30.15" -] -``` - -### Kein Client Secret - -Das Setup erzeugt ein nicht exportierbares RSA-Zertifikat in: - -```text -Cert:\LocalMachine\My -``` - -Der Scheduled Task läuft als `SYSTEM` und lädt dieses Zertifikat direkt aus dem Maschinen-Zertifikatsspeicher. - -### Graph-Berechtigung - -Die V1 vergibt ausschließlich: - -```text -Microsoft Graph -Application -Mail.Send -``` - -Keine `Mail.ReadWrite`, `Directory.Read.All`, SMTP-/IMAP- oder Exchange-Full-Access-Permission ist für den Relaybetrieb nötig. - -**Wichtig:** `Mail.Send` als Application Permission ist grundsätzlich eine weitreichende Berechtigung. Die V1 erzwingt zwar lokal das konfigurierte Senderpostfach, beschränkt die Entra-/Exchange-Berechtigung aber noch nicht serverseitig auf genau dieses Postfach. - -Für eine nächste Version sollte zusätzlich **Exchange Online Application RBAC** bzw. die jeweils aktuelle Microsoft-Methode zur Ressourcenscope-Begrenzung integriert werden. - -## Bekannte Grenzen von V1 - -- SMTP-Verbindungen werden seriell verarbeitet -- kein SMTP AUTH -- kein STARTTLS auf der internen SMTP-Seite -- IPv4-Allowlist; IPv6 wird nicht freigegeben -- kein Web-/GUI-Frontend -- keine DSN/Bounce-Erzeugung -- Envelope-Empfänger werden protokolliert; Graph erhält primär die Empfänger aus den MIME-Headern -- kein serverseitiges Exchange-Mailbox-Scoping im Setup - -Für typische Drucker, Scanner, NAS, Monitoring- und Server-Alerts sollte diese V1 als Test-/Pilotversion ausreichen. - -## Deinstallation - -Task stoppen/löschen: - -```powershell -Stop-ScheduledTask -TaskName "SMTPGraphRelay" -ErrorAction SilentlyContinue -Unregister-ScheduledTask -TaskName "SMTPGraphRelay" -Confirm:$false -``` - -Firewallregel entfernen (Port ggf. anpassen): - -```powershell -Remove-NetFirewallRule -DisplayName "SMTPGraphRelay TCP 2525" -``` - -Die Entra-App und das Zertifikat werden absichtlich **nicht automatisch gelöscht**, damit bei einer Deinstallation keine Cloud-Credentials versehentlich entfernt werden. +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. diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 index 79fd668..49ed907 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: EHLO/HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT + V1.2: robuste Queue, statuscodeabhängiger Graph-Retry, EHLO/HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT #> [CmdletBinding()] @@ -164,13 +164,268 @@ function Set-MimeSender { 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 '(?