Dateien nach "/" hochladen
This commit is contained in:
@@ -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
|
```text
|
||||||
Drucker / NAS / Server / Monitoring
|
queue\
|
||||||
|
|
├── incoming\
|
||||||
| SMTP (lokal)
|
├── pending\
|
||||||
v
|
└── processing\
|
||||||
SMTPGraphRelay
|
|
||||||
|
|
failed\
|
||||||
| HTTPS / OAuth2 / Microsoft Graph
|
|
||||||
v
|
|
||||||
Microsoft 365
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
Beim Start werden alte V1-Mails direkt aus `queue\` nach `pending\` migriert.
|
||||||
- `EHLO`, `HELO`, `MAIL FROM`, mehrere `RCPT TO`, `DATA`, `RSET`, `NOOP`, `QUIT`
|
Mails, die nach einem Absturz noch in `processing\` liegen, werden nach `pending\`
|
||||||
- IPv4-Allowlist per CIDR
|
zurückgestellt.
|
||||||
- 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`
|
|
||||||
|
|
||||||
## Voraussetzungen
|
## Retry
|
||||||
|
|
||||||
- Windows 10/11 oder Windows Server
|
- HTTP 429: `Retry-After` wird verwendet; fehlt es, exponentielles Backoff.
|
||||||
- Windows PowerShell 5.1
|
- HTTP 408 und 5xx: Retry nach `RetryMinutes` aus `config.json`.
|
||||||
- PowerShell als Administrator
|
- Netzwerk-/Transportfehler ohne HTTP-Code: Retry.
|
||||||
- Internetzugang zu Microsoft Graph / Entra
|
- typische permanente 4xx wie 400/401/403/404/413/415/422: direkt nach `failed`.
|
||||||
- M365-/Entra-Admin, der App-Registrierungen und App Permissions vergeben darf
|
- nach `MaxRetries`: nach `failed`.
|
||||||
- bestehendes Exchange-Online-Postfach für den konfigurierten Absender
|
|
||||||
|
|
||||||
## Installation
|
## Installation über bestehende Version
|
||||||
|
|
||||||
1. ZIP entpacken, z. B.:
|
1. Scheduled Task stoppen:
|
||||||
|
|
||||||
```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:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Stop-ScheduledTask -TaskName "SMTPGraphRelay"
|
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
|
```powershell
|
||||||
Get-ScheduledTask -TaskName "SMTPGraphRelay" | Get-ScheduledTaskInfo
|
Start-ScheduledTask -TaskName "SMTPGraphRelay"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test vom Relay-PC
|
5. Log prüfen:
|
||||||
|
|
||||||
Wenn `Send-MailMessage` noch vorhanden ist:
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Send-MailMessage `
|
Get-Content "C:\Program Files\SMTPGraphRelay\logs\SMTPGraphRelay.log" -Tail 100
|
||||||
-SmtpServer 127.0.0.1 `
|
|
||||||
-Port 2525 `
|
|
||||||
-From "test@local.invalid" `
|
|
||||||
-To "dein.name@example.com" `
|
|
||||||
-Subject "SMTPGraphRelay Test" `
|
|
||||||
-Body "Hallo aus SMTPGraphRelay"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
Eine absolut garantierte Exactly-Once-Zustellung kann ein SMTP→Graph-Gateway nicht
|
||||||
SMTPGraphRelay\
|
sicherstellen: Falls Graph die Mail bereits angenommen hat und der lokale Prozess exakt
|
||||||
├── SMTPGraphRelay.ps1
|
vor dem Löschen der Queue-Datei abstürzt, kann ein erneuter Versuch theoretisch ein Duplikat
|
||||||
├── Setup-SMTPGraphRelay.ps1
|
erzeugen. V1.2 reduziert dieses Risiko durch die Processing-Queue, kann es aber nicht
|
||||||
├── config.json
|
vollständig eliminieren.
|
||||||
├── 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.
|
|
||||||
|
|||||||
+400
-42
@@ -7,7 +7,7 @@
|
|||||||
Nimmt lokale SMTP-Mails an, speichert sie als .eml in einer Queue und sendet sie
|
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.
|
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()]
|
[CmdletBinding()]
|
||||||
@@ -164,13 +164,268 @@ function Set-MimeSender {
|
|||||||
return $latin1.GetBytes($headers + "`r`n`r`n" + $body)
|
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 '(?<!\d)(400|401|403|404|408|409|410|412|413|415|422|423|429|500|501|502|503|504)(?!\d)') {
|
||||||
|
$statusCode = [int]$matches[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject]@{
|
||||||
|
StatusCode = $statusCode
|
||||||
|
RetryAfterSeconds = $retryAfterSeconds
|
||||||
|
Message = $message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RetryDecision {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
$FailureInfo,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[int]$RetryCount
|
||||||
|
)
|
||||||
|
|
||||||
|
$statusCode = $FailureInfo.StatusCode
|
||||||
|
|
||||||
|
# Graph Throttling: Retry-After bevorzugen.
|
||||||
|
if ($statusCode -eq 429) {
|
||||||
|
if ($FailureInfo.RetryAfterSeconds -and $FailureInfo.RetryAfterSeconds -gt 0) {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Retry = $true
|
||||||
|
DelaySeconds = [int]$FailureInfo.RetryAfterSeconds
|
||||||
|
Reason = "Graph 429 / Retry-After"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Falls kein Retry-After vorhanden ist: exponentielles Backoff.
|
||||||
|
$seconds = [Math]::Min(3600, [Math]::Pow(2, [Math]::Min($RetryCount, 10)) * 5)
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Retry = $true
|
||||||
|
DelaySeconds = [int]$seconds
|
||||||
|
Reason = "Graph 429 / exponentielles Backoff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Serverfehler und Request Timeout sind typischerweise temporär.
|
||||||
|
if ($statusCode -eq 408 -or ($statusCode -ge 500 -and $statusCode -le 599)) {
|
||||||
|
$delays = @($script:Config.Queue.RetryMinutes)
|
||||||
|
$index = [Math]::Min([Math]::Max($RetryCount - 1, 0), $delays.Count - 1)
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Retry = $true
|
||||||
|
DelaySeconds = ([int]$delays[$index] * 60)
|
||||||
|
Reason = "temporärer HTTP-Fehler $statusCode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Typische Auth-/RBAC-/Requestfehler sind ohne Konfigurationsänderung permanent.
|
||||||
|
if ($statusCode -in @(400,401,403,404,409,410,412,413,415,422,423)) {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Retry = $false
|
||||||
|
DelaySeconds = 0
|
||||||
|
Reason = "permanenter HTTP-Fehler $statusCode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Kein Statuscode: meist DNS/TLS/Socket/Timeout. Mit normalem Retry behandeln.
|
||||||
|
if ($null -eq $statusCode) {
|
||||||
|
$delays = @($script:Config.Queue.RetryMinutes)
|
||||||
|
$index = [Math]::Min([Math]::Max($RetryCount - 1, 0), $delays.Count - 1)
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Retry = $true
|
||||||
|
DelaySeconds = ([int]$delays[$index] * 60)
|
||||||
|
Reason = "Netzwerk-/Transportfehler"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unbekannte 4xx lieber nicht endlos wiederholen.
|
||||||
|
if ($statusCode -ge 400 -and $statusCode -le 499) {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Retry = $false
|
||||||
|
DelaySeconds = 0
|
||||||
|
Reason = "unbekannter Clientfehler $statusCode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Defensive Default-Policy.
|
||||||
|
$delays = @($script:Config.Queue.RetryMinutes)
|
||||||
|
$index = [Math]::Min([Math]::Max($RetryCount - 1, 0), $delays.Count - 1)
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Retry = $true
|
||||||
|
DelaySeconds = ([int]$delays[$index] * 60)
|
||||||
|
Reason = "unbekannter temporärer Fehler"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Move-QueueItem {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$SourceEml,
|
||||||
|
[Parameter(Mandatory)][string]$DestinationDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null
|
||||||
|
|
||||||
|
$sourceMeta = "$SourceEml.json"
|
||||||
|
$destinationEml = Join-Path $DestinationDirectory (Split-Path $SourceEml -Leaf)
|
||||||
|
$destinationMeta = "$destinationEml.json"
|
||||||
|
|
||||||
|
# Metadaten zuerst bewegen, .eml zuletzt. Dadurch sieht der Worker niemals
|
||||||
|
# eine neue .eml-Datei ohne bereits vorhandene Metadaten.
|
||||||
|
if (Test-Path -LiteralPath $sourceMeta) {
|
||||||
|
Move-Item -LiteralPath $sourceMeta -Destination $destinationMeta -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Move-Item -LiteralPath $SourceEml -Destination $destinationEml -Force
|
||||||
|
|
||||||
|
return $destinationEml
|
||||||
|
}
|
||||||
|
|
||||||
function Send-QueuedMail {
|
function Send-QueuedMail {
|
||||||
param([Parameter(Mandatory)][string]$FilePath)
|
param([Parameter(Mandatory)][string]$FilePath)
|
||||||
|
|
||||||
|
$dirs = Get-QueueDirectories
|
||||||
$metaPath = "$FilePath.json"
|
$metaPath = "$FilePath.json"
|
||||||
$meta = $null
|
$meta = $null
|
||||||
|
|
||||||
if (Test-Path -LiteralPath $metaPath) {
|
if (Test-Path -LiteralPath $metaPath) {
|
||||||
$meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
try {
|
||||||
|
$meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
Write-Log "Queue-Metadaten unlesbar für $(Split-Path $FilePath -Leaf): $($_.Exception.Message)" "WARN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $meta) {
|
||||||
|
$meta = [pscustomobject]@{
|
||||||
|
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
|
||||||
|
RetryCount = 0
|
||||||
|
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$bytes = [IO.File]::ReadAllBytes($FilePath)
|
$bytes = [IO.File]::ReadAllBytes($FilePath)
|
||||||
@@ -179,13 +434,13 @@ function Send-QueuedMail {
|
|||||||
$bytes = Set-MimeSender -MimeBytes $bytes -Sender $script:Config.Graph.SenderMailbox
|
$bytes = Set-MimeSender -MimeBytes $bytes -Sender $script:Config.Graph.SenderMailbox
|
||||||
}
|
}
|
||||||
|
|
||||||
Get-GraphConnection
|
|
||||||
|
|
||||||
$sender = [Uri]::EscapeDataString($script:Config.Graph.SenderMailbox)
|
|
||||||
$uri = "https://graph.microsoft.com/v1.0/users/$sender/sendMail"
|
|
||||||
$base64 = [Convert]::ToBase64String($bytes)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
Get-GraphConnection
|
||||||
|
|
||||||
|
$sender = [Uri]::EscapeDataString($script:Config.Graph.SenderMailbox)
|
||||||
|
$uri = "https://graph.microsoft.com/v1.0/users/$sender/sendMail"
|
||||||
|
$base64 = [Convert]::ToBase64String($bytes)
|
||||||
|
|
||||||
Invoke-MgGraphRequest `
|
Invoke-MgGraphRequest `
|
||||||
-Method POST `
|
-Method POST `
|
||||||
-Uri $uri `
|
-Uri $uri `
|
||||||
@@ -193,60 +448,145 @@ function Send-QueuedMail {
|
|||||||
-ContentType "text/plain" `
|
-ContentType "text/plain" `
|
||||||
-OutputType PSObject | Out-Null
|
-OutputType PSObject | Out-Null
|
||||||
|
|
||||||
Write-Log "Mail gesendet: $(Split-Path $FilePath -Leaf)"
|
# Graph sendMail liefert bei Annahme 202 Accepted.
|
||||||
|
# Invoke-MgGraphRequest wirft bei Fehlern eine Exception; kein Fehler bedeutet hier angenommen.
|
||||||
|
Write-Log "Mail von Graph angenommen: $(Split-Path $FilePath -Leaf)"
|
||||||
|
|
||||||
Remove-Item -LiteralPath $FilePath -Force
|
Remove-Item -LiteralPath $FilePath -Force
|
||||||
if (Test-Path -LiteralPath $metaPath) { Remove-Item -LiteralPath $metaPath -Force }
|
if (Test-Path -LiteralPath $metaPath) {
|
||||||
|
Remove-Item -LiteralPath $metaPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
$retryCount = 0
|
$retryCount = 0
|
||||||
if ($meta -and $null -ne $meta.RetryCount) { $retryCount = [int]$meta.RetryCount }
|
if ($null -ne $meta.RetryCount) {
|
||||||
|
$retryCount = [int]$meta.RetryCount
|
||||||
|
}
|
||||||
$retryCount++
|
$retryCount++
|
||||||
|
|
||||||
|
$failure = Get-GraphFailureInfo -ErrorRecord $_
|
||||||
|
$decision = Get-RetryDecision -FailureInfo $failure -RetryCount $retryCount
|
||||||
$maxRetries = [int]$script:Config.Queue.MaxRetries
|
$maxRetries = [int]$script:Config.Queue.MaxRetries
|
||||||
Write-Log "Graph-Versand fehlgeschlagen (Versuch $retryCount/$maxRetries): $($_.Exception.Message)" "WARN"
|
|
||||||
|
|
||||||
if ($retryCount -ge $maxRetries) {
|
$statusText = if ($null -ne $failure.StatusCode) {
|
||||||
$failedDir = Resolve-PathFromConfig $script:Config.Paths.Failed
|
"HTTP $($failure.StatusCode)"
|
||||||
New-Item -ItemType Directory -Path $failedDir -Force | Out-Null
|
|
||||||
Move-Item -LiteralPath $FilePath -Destination (Join-Path $failedDir (Split-Path $FilePath -Leaf)) -Force
|
|
||||||
if (Test-Path -LiteralPath $metaPath) {
|
|
||||||
Move-Item -LiteralPath $metaPath -Destination (Join-Path $failedDir (Split-Path $metaPath -Leaf)) -Force
|
|
||||||
}
|
|
||||||
Write-Log "Mail nach $retryCount Fehlversuchen nach FAILED verschoben." "ERROR"
|
|
||||||
} else {
|
} else {
|
||||||
$delays = @($script:Config.Queue.RetryMinutes)
|
"ohne HTTP-Status"
|
||||||
$delay = if ($retryCount -le $delays.Count) { [int]$delays[$retryCount - 1] } else { [int]$delays[-1] }
|
}
|
||||||
$next = (Get-Date).AddMinutes($delay)
|
|
||||||
|
if (-not $decision.Retry) {
|
||||||
|
Write-Log ("Graph-Versand permanent fehlgeschlagen ({0}, {1}): {2}" -f $statusText, $decision.Reason, $failure.Message) "ERROR"
|
||||||
|
|
||||||
$newMeta = [ordered]@{
|
$newMeta = [ordered]@{
|
||||||
|
ReceivedUtc = $meta.ReceivedUtc
|
||||||
|
RemoteAddress = $meta.RemoteAddress
|
||||||
|
EnvelopeFrom = $meta.EnvelopeFrom
|
||||||
|
EnvelopeRecipients = @($meta.EnvelopeRecipients)
|
||||||
RetryCount = $retryCount
|
RetryCount = $retryCount
|
||||||
NextAttemptUtc = $next.ToUniversalTime().ToString("o")
|
FailedUtc = [DateTime]::UtcNow.ToString("o")
|
||||||
LastError = $_.Exception.Message
|
LastStatusCode = $failure.StatusCode
|
||||||
|
LastError = $failure.Message
|
||||||
|
FailureReason = $decision.Reason
|
||||||
}
|
}
|
||||||
$newMeta | ConvertTo-Json | Set-Content -LiteralPath $metaPath -Encoding UTF8
|
$newMeta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8
|
||||||
|
|
||||||
|
[void](Move-QueueItem -SourceEml $FilePath -DestinationDirectory $dirs.Failed)
|
||||||
|
return $false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($retryCount -ge $maxRetries) {
|
||||||
|
Write-Log ("Graph-Versand nach {0} Versuchen endgültig fehlgeschlagen ({1}): {2}" -f $retryCount, $statusText, $failure.Message) "ERROR"
|
||||||
|
|
||||||
|
$newMeta = [ordered]@{
|
||||||
|
ReceivedUtc = $meta.ReceivedUtc
|
||||||
|
RemoteAddress = $meta.RemoteAddress
|
||||||
|
EnvelopeFrom = $meta.EnvelopeFrom
|
||||||
|
EnvelopeRecipients = @($meta.EnvelopeRecipients)
|
||||||
|
RetryCount = $retryCount
|
||||||
|
FailedUtc = [DateTime]::UtcNow.ToString("o")
|
||||||
|
LastStatusCode = $failure.StatusCode
|
||||||
|
LastError = $failure.Message
|
||||||
|
FailureReason = "MaxRetries erreicht"
|
||||||
|
}
|
||||||
|
$newMeta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8
|
||||||
|
|
||||||
|
[void](Move-QueueItem -SourceEml $FilePath -DestinationDirectory $dirs.Failed)
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$next = (Get-Date).AddSeconds([int]$decision.DelaySeconds)
|
||||||
|
$newMeta = [ordered]@{
|
||||||
|
ReceivedUtc = $meta.ReceivedUtc
|
||||||
|
RemoteAddress = $meta.RemoteAddress
|
||||||
|
EnvelopeFrom = $meta.EnvelopeFrom
|
||||||
|
EnvelopeRecipients = @($meta.EnvelopeRecipients)
|
||||||
|
RetryCount = $retryCount
|
||||||
|
NextAttemptUtc = $next.ToUniversalTime().ToString("o")
|
||||||
|
LastAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
||||||
|
LastStatusCode = $failure.StatusCode
|
||||||
|
LastError = $failure.Message
|
||||||
|
RetryReason = $decision.Reason
|
||||||
|
}
|
||||||
|
$newMeta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8
|
||||||
|
|
||||||
|
$delayText = if ($decision.DelaySeconds -ge 60) {
|
||||||
|
"{0:N1} Min." -f ($decision.DelaySeconds / 60)
|
||||||
|
} else {
|
||||||
|
"$($decision.DelaySeconds) Sek."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log ("Graph-Versand fehlgeschlagen (Versuch {0}/{1}, {2}, {3}). Neuer Versuch in {4}: {5}" -f `
|
||||||
|
$retryCount, $maxRetries, $statusText, $decision.Reason, $delayText, $failure.Message) "WARN"
|
||||||
|
|
||||||
|
[void](Move-QueueItem -SourceEml $FilePath -DestinationDirectory $dirs.Pending)
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Process-Queue {
|
function Process-Queue {
|
||||||
$queueDir = Resolve-PathFromConfig $script:Config.Paths.Queue
|
$dirs = Get-QueueDirectories
|
||||||
New-Item -ItemType Directory -Path $queueDir -Force | Out-Null
|
|
||||||
|
|
||||||
foreach ($file in Get-ChildItem -LiteralPath $queueDir -Filter "*.eml" -File | Sort-Object CreationTimeUtc) {
|
foreach ($file in Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File | Sort-Object CreationTimeUtc) {
|
||||||
$metaPath = "$($file.FullName).json"
|
$metaPath = "$($file.FullName).json"
|
||||||
|
|
||||||
if (Test-Path -LiteralPath $metaPath) {
|
if (Test-Path -LiteralPath $metaPath) {
|
||||||
try {
|
try {
|
||||||
$meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
$meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
|
||||||
if ($meta.NextAttemptUtc) {
|
if ($meta.NextAttemptUtc) {
|
||||||
$next = [DateTime]::Parse($meta.NextAttemptUtc).ToUniversalTime()
|
$next = [DateTime]::Parse($meta.NextAttemptUtc).ToUniversalTime()
|
||||||
if ($next -gt [DateTime]::UtcNow) { continue }
|
if ($next -gt [DateTime]::UtcNow) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
}
|
||||||
|
catch {
|
||||||
|
Write-Log "Queue-Metadaten konnten nicht gelesen werden: $metaPath" "WARN"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[void](Send-QueuedMail -FilePath $file.FullName)
|
try {
|
||||||
|
$processingPath = Move-QueueItem `
|
||||||
|
-SourceEml $file.FullName `
|
||||||
|
-DestinationDirectory $dirs.Processing
|
||||||
|
|
||||||
|
[void](Send-QueuedMail -FilePath $processingPath)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Log "Queue-Verarbeitung für $($file.Name) fehlgeschlagen: $($_.Exception.Message)" "ERROR"
|
||||||
|
|
||||||
|
# Falls das Verschieben nach processing bereits geklappt hat, zurück nach pending.
|
||||||
|
$possibleProcessing = Join-Path $dirs.Processing $file.Name
|
||||||
|
if (Test-Path -LiteralPath $possibleProcessing) {
|
||||||
|
try {
|
||||||
|
[void](Move-QueueItem -SourceEml $possibleProcessing -DestinationDirectory $dirs.Pending)
|
||||||
|
} catch {
|
||||||
|
Write-Log "Queue-Recovery fehlgeschlagen für $($file.Name): $($_.Exception.Message)" "ERROR"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,16 +607,18 @@ function Save-SmtpMessage {
|
|||||||
[string]$RemoteAddress
|
[string]$RemoteAddress
|
||||||
)
|
)
|
||||||
|
|
||||||
$queueDir = Resolve-PathFromConfig $script:Config.Paths.Queue
|
$dirs = Get-QueueDirectories
|
||||||
New-Item -ItemType Directory -Path $queueDir -Force | Out-Null
|
|
||||||
|
|
||||||
$id = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmssfff"), ([guid]::NewGuid().ToString("N").Substring(0,8))
|
$id = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmssfff"), ([guid]::NewGuid().ToString("N").Substring(0,8))
|
||||||
$path = Join-Path $queueDir "$id.eml"
|
|
||||||
|
$incomingEmlTmp = Join-Path $dirs.Incoming "$id.eml.tmp"
|
||||||
|
$incomingMetaTmp = Join-Path $dirs.Incoming "$id.eml.json.tmp"
|
||||||
|
|
||||||
|
$pendingEml = Join-Path $dirs.Pending "$id.eml"
|
||||||
|
$pendingMeta = "$pendingEml.json"
|
||||||
|
|
||||||
# SMTP DATA wird in CRLF normalisiert.
|
# SMTP DATA wird in CRLF normalisiert.
|
||||||
$raw = ($Lines -join "`r`n") + "`r`n"
|
$raw = ($Lines -join "`r`n") + "`r`n"
|
||||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
[IO.File]::WriteAllText($path, $raw, $utf8NoBom)
|
|
||||||
|
|
||||||
$meta = [ordered]@{
|
$meta = [ordered]@{
|
||||||
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
|
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
|
||||||
@@ -286,10 +628,27 @@ function Save-SmtpMessage {
|
|||||||
RetryCount = 0
|
RetryCount = 0
|
||||||
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
||||||
}
|
}
|
||||||
$meta | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath "$path.json" -Encoding UTF8
|
|
||||||
|
try {
|
||||||
|
# Erst vollständig in incoming schreiben.
|
||||||
|
[IO.File]::WriteAllText($incomingEmlTmp, $raw, $utf8NoBom)
|
||||||
|
$meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $incomingMetaTmp -Encoding UTF8
|
||||||
|
|
||||||
|
# Metadaten zuerst finalisieren, Maildatei zuletzt.
|
||||||
|
# Erst wenn die .eml in pending liegt, ist sie für den Worker sichtbar.
|
||||||
|
Move-Item -LiteralPath $incomingMetaTmp -Destination $pendingMeta -Force
|
||||||
|
Move-Item -LiteralPath $incomingEmlTmp -Destination $pendingEml -Force
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Remove-Item -LiteralPath $incomingEmlTmp -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $incomingMetaTmp -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $pendingMeta -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -LiteralPath $pendingEml -Force -ErrorAction SilentlyContinue
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
|
||||||
Write-Log "Mail angenommen: $id | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress"
|
Write-Log "Mail angenommen: $id | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress"
|
||||||
return $path
|
return $pendingEml
|
||||||
}
|
}
|
||||||
|
|
||||||
function Write-SmtpLine {
|
function Write-SmtpLine {
|
||||||
@@ -432,9 +791,8 @@ if (-not (Test-Path -LiteralPath $ConfigPath)) {
|
|||||||
|
|
||||||
$script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
$script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||||
|
|
||||||
foreach ($p in @($script:Config.Paths.Queue, $script:Config.Paths.Failed, $script:Config.Paths.Logs)) {
|
New-Item -ItemType Directory -Path (Resolve-PathFromConfig $script:Config.Paths.Logs) -Force | Out-Null
|
||||||
New-Item -ItemType Directory -Path (Resolve-PathFromConfig $p) -Force | Out-Null
|
Initialize-QueueDirectories
|
||||||
}
|
|
||||||
|
|
||||||
$listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress)
|
$listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress)
|
||||||
$listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port)
|
$listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port)
|
||||||
|
|||||||
Reference in New Issue
Block a user