Dateien nach "/" hochladen

This commit is contained in:
2026-08-13 22:57:09 +02:00
parent a81e0cb710
commit e4ceecad24
2 changed files with 441 additions and 264 deletions
+400 -42
View File
@@ -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 '(?<!\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 {
param([Parameter(Mandatory)][string]$FilePath)
$dirs = Get-QueueDirectories
$metaPath = "$FilePath.json"
$meta = $null
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)
@@ -179,13 +434,13 @@ function Send-QueuedMail {
$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 {
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 `
-Method POST `
-Uri $uri `
@@ -193,60 +448,145 @@ function Send-QueuedMail {
-ContentType "text/plain" `
-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
if (Test-Path -LiteralPath $metaPath) { Remove-Item -LiteralPath $metaPath -Force }
if (Test-Path -LiteralPath $metaPath) {
Remove-Item -LiteralPath $metaPath -Force
}
return $true
}
catch {
$retryCount = 0
if ($meta -and $null -ne $meta.RetryCount) { $retryCount = [int]$meta.RetryCount }
if ($null -ne $meta.RetryCount) {
$retryCount = [int]$meta.RetryCount
}
$retryCount++
$failure = Get-GraphFailureInfo -ErrorRecord $_
$decision = Get-RetryDecision -FailureInfo $failure -RetryCount $retryCount
$maxRetries = [int]$script:Config.Queue.MaxRetries
Write-Log "Graph-Versand fehlgeschlagen (Versuch $retryCount/$maxRetries): $($_.Exception.Message)" "WARN"
if ($retryCount -ge $maxRetries) {
$failedDir = Resolve-PathFromConfig $script:Config.Paths.Failed
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"
$statusText = if ($null -ne $failure.StatusCode) {
"HTTP $($failure.StatusCode)"
} else {
$delays = @($script:Config.Queue.RetryMinutes)
$delay = if ($retryCount -le $delays.Count) { [int]$delays[$retryCount - 1] } else { [int]$delays[-1] }
$next = (Get-Date).AddMinutes($delay)
"ohne HTTP-Status"
}
if (-not $decision.Retry) {
Write-Log ("Graph-Versand permanent fehlgeschlagen ({0}, {1}): {2}" -f $statusText, $decision.Reason, $failure.Message) "ERROR"
$newMeta = [ordered]@{
ReceivedUtc = $meta.ReceivedUtc
RemoteAddress = $meta.RemoteAddress
EnvelopeFrom = $meta.EnvelopeFrom
EnvelopeRecipients = @($meta.EnvelopeRecipients)
RetryCount = $retryCount
NextAttemptUtc = $next.ToUniversalTime().ToString("o")
LastError = $_.Exception.Message
FailedUtc = [DateTime]::UtcNow.ToString("o")
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
}
}
function Process-Queue {
$queueDir = Resolve-PathFromConfig $script:Config.Paths.Queue
New-Item -ItemType Directory -Path $queueDir -Force | Out-Null
$dirs = Get-QueueDirectories
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"
if (Test-Path -LiteralPath $metaPath) {
try {
$meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
if ($meta.NextAttemptUtc) {
$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
)
$queueDir = Resolve-PathFromConfig $script:Config.Paths.Queue
New-Item -ItemType Directory -Path $queueDir -Force | Out-Null
$dirs = Get-QueueDirectories
$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.
$raw = ($Lines -join "`r`n") + "`r`n"
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[IO.File]::WriteAllText($path, $raw, $utf8NoBom)
$meta = [ordered]@{
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
@@ -286,10 +628,27 @@ function Save-SmtpMessage {
RetryCount = 0
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"
return $path
return $pendingEml
}
function Write-SmtpLine {
@@ -432,9 +791,8 @@ if (-not (Test-Path -LiteralPath $ConfigPath)) {
$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 $p) -Force | Out-Null
}
New-Item -ItemType Directory -Path (Resolve-PathFromConfig $script:Config.Paths.Logs) -Force | Out-Null
Initialize-QueueDirectories
$listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress)
$listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port)