diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 index fb17a5a..058c2f0 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.5: Log-Rotation, Zertifikatsüberwachung, parallele SMTP-Clients, Queue-Worker und robuste Graph-Retry-Logik + V1.6: Queue-ID/Received/Message-ID sowie SMTP-/Queue-Backpressure und Session-Limits #> [CmdletBinding()] @@ -550,6 +550,7 @@ function Send-QueuedMail { $dirs = Get-QueueDirectories $metaPath = "$FilePath.json" $meta = $null + $queueId = [IO.Path]::GetFileNameWithoutExtension($FilePath) if (Test-Path -LiteralPath $metaPath) { try { @@ -561,12 +562,17 @@ function Send-QueuedMail { if (-not $meta) { $meta = [pscustomobject]@{ + QueueId = $queueId ReceivedUtc = [DateTime]::UtcNow.ToString("o") RetryCount = 0 NextAttemptUtc = [DateTime]::UtcNow.ToString("o") } } + if ($meta -and $meta.PSObject.Properties.Name -contains "QueueId" -and -not [string]::IsNullOrWhiteSpace([string]$meta.QueueId)) { + $queueId = [string]$meta.QueueId + } + $bytes = [IO.File]::ReadAllBytes($FilePath) if ($script:Config.Graph.ForceSender) { @@ -589,7 +595,7 @@ function Send-QueuedMail { # 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)" + Write-Log ("[{0}] Mail von Graph angenommen (202 Accepted)." -f $queueId) Remove-Item -LiteralPath $FilePath -Force if (Test-Path -LiteralPath $metaPath) { @@ -616,9 +622,10 @@ function Send-QueuedMail { } if (-not $decision.Retry) { - Write-Log ("Graph-Versand permanent fehlgeschlagen ({0}, {1}): {2}" -f $statusText, $decision.Reason, $failure.Message) "ERROR" + Write-Log ("[{0}] Graph-Versand permanent fehlgeschlagen ({1}, {2}): {3}" -f $queueId, $statusText, $decision.Reason, $failure.Message) "ERROR" $newMeta = [ordered]@{ + QueueId = $queueId ReceivedUtc = $meta.ReceivedUtc RemoteAddress = $meta.RemoteAddress EnvelopeFrom = $meta.EnvelopeFrom @@ -636,9 +643,10 @@ function Send-QueuedMail { } if ($retryCount -ge $maxRetries) { - Write-Log ("Graph-Versand nach {0} Versuchen endgültig fehlgeschlagen ({1}): {2}" -f $retryCount, $statusText, $failure.Message) "ERROR" + Write-Log ("[{0}] Graph-Versand nach {1} Versuchen endgültig fehlgeschlagen ({2}): {3}" -f $queueId, $retryCount, $statusText, $failure.Message) "ERROR" $newMeta = [ordered]@{ + QueueId = $queueId ReceivedUtc = $meta.ReceivedUtc RemoteAddress = $meta.RemoteAddress EnvelopeFrom = $meta.EnvelopeFrom @@ -657,6 +665,7 @@ function Send-QueuedMail { $next = (Get-Date).AddSeconds([int]$decision.DelaySeconds) $newMeta = [ordered]@{ + QueueId = $queueId ReceivedUtc = $meta.ReceivedUtc RemoteAddress = $meta.RemoteAddress EnvelopeFrom = $meta.EnvelopeFrom @@ -676,8 +685,8 @@ function Send-QueuedMail { "$($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" + Write-Log ("[{0}] Graph-Versand fehlgeschlagen (Versuch {1}/{2}, {3}, {4}). Neuer Versuch in {5}: {6}" -f ` + $queueId, $retryCount, $maxRetries, $statusText, $decision.Reason, $delayText, $failure.Message) "WARN" [void](Move-QueueItem -SourceEml $FilePath -DestinationDirectory $dirs.Pending) return $false @@ -729,6 +738,185 @@ function Process-Queue { } } + +function New-QueueId { + # Kurz genug für Logs/SMTP-Antworten, aber mit sehr hoher Kollisionsreserve. + return ([guid]::NewGuid().ToString("N").Substring(0,12).ToUpperInvariant()) +} + +function Add-RelayMessageHeaders { + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [AllowEmptyString()] + [System.Collections.Generic.List[string]]$Lines, + + [Parameter(Mandatory)] + [string]$QueueId, + + [Parameter(Mandatory)] + [string]$RemoteAddress + ) + + $result = New-Object System.Collections.Generic.List[string] + + # Header/Body-Grenze suchen. Falls eine kaputte Minimalnachricht keine Leerzeile + # besitzt, behandeln wir den kompletten Inhalt als Headerblock und ergänzen die + # notwendige Trennzeile. + $separatorIndex = -1 + for ($i = 0; $i -lt $Lines.Count; $i++) { + if ($Lines[$i] -eq "") { + $separatorIndex = $i + break + } + } + + $headerEnd = if ($separatorIndex -ge 0) { $separatorIndex } else { $Lines.Count } + + $hasMessageId = $false + for ($i = 0; $i -lt $headerEnd; $i++) { + if ($Lines[$i] -match '^(?i)Message-ID\s*:') { + $hasMessageId = $true + break + } + } + + $hostname = [string]$script:Config.Smtp.Hostname + if ([string]::IsNullOrWhiteSpace($hostname)) { + $hostname = $env:COMPUTERNAME + } + + $date = [DateTimeOffset]::Now.ToString("ddd, dd MMM yyyy HH:mm:ss zzz", [Globalization.CultureInfo]::InvariantCulture) + $received = "Received: from [$RemoteAddress] by $hostname with SMTPGraphRelay; $date" + + # Received immer ganz oben vor die ursprünglichen Header setzen. + $result.Add($received) + + if (-not $hasMessageId) { + $safeHost = ($hostname -replace '[^A-Za-z0-9._-]', '-') + $messageId = "<$QueueId.$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))@$safeHost>" + $result.Add("Message-ID: $messageId") + } + + for ($i = 0; $i -lt $Lines.Count; $i++) { + $result.Add($Lines[$i]) + } + + if ($separatorIndex -lt 0) { + $result.Add("") + } + + return $result +} + +function Get-SmtpLimits { + $maxRecipients = 50 + $maxMessagesPerConnection = 25 + $maxPendingMessages = 5000 + $minFreeDiskSpaceMB = 1024 + + try { + if ($script:Config.Smtp.PSObject.Properties.Name -contains "MaxRecipients") { + $v = [int]$script:Config.Smtp.MaxRecipients + if ($v -ge 1 -and $v -le 1000) { $maxRecipients = $v } + } + + if ($script:Config.Smtp.PSObject.Properties.Name -contains "MaxMessagesPerConnection") { + $v = [int]$script:Config.Smtp.MaxMessagesPerConnection + if ($v -ge 1 -and $v -le 10000) { $maxMessagesPerConnection = $v } + } + + if ($script:Config.Queue.PSObject.Properties.Name -contains "MaxPendingMessages") { + $v = [int]$script:Config.Queue.MaxPendingMessages + if ($v -ge 1 -and $v -le 1000000) { $maxPendingMessages = $v } + } + + if ($script:Config.Queue.PSObject.Properties.Name -contains "MinFreeDiskSpaceMB") { + $v = [int]$script:Config.Queue.MinFreeDiskSpaceMB + if ($v -ge 0 -and $v -le 1048576) { $minFreeDiskSpaceMB = $v } + } + } + catch {} + + return [pscustomobject]@{ + MaxRecipients = $maxRecipients + MaxMessagesPerConnection = $maxMessagesPerConnection + MaxPendingMessages = $maxPendingMessages + MinFreeDiskSpaceMB = $minFreeDiskSpaceMB + } +} + +function Get-QueuePressure { + $dirs = Get-QueueDirectories + $limits = Get-SmtpLimits + + try { + $pendingCount = @( + Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File -ErrorAction Stop + ).Count + } + catch { + return [pscustomobject]@{ + Accept = $false + SmtpCode = "451 4.3.0 SMTPGraphRelay queue temporarily unavailable" + Reason = "Pending-Queue konnte nicht gelesen werden: $($_.Exception.Message)" + PendingCount = $null + FreeSpaceMB = $null + } + } + + if ($pendingCount -ge $limits.MaxPendingMessages) { + return [pscustomobject]@{ + Accept = $false + SmtpCode = "452 4.3.1 SMTPGraphRelay queue limit reached" + Reason = "Pending-Queue-Limit erreicht ($pendingCount/$($limits.MaxPendingMessages))" + PendingCount = $pendingCount + FreeSpaceMB = $null + } + } + + $freeSpaceMB = $null + + try { + $fullQueuePath = [IO.Path]::GetFullPath($dirs.Root) + $root = [IO.Path]::GetPathRoot($fullQueuePath) + + if ($root) { + $driveInfo = New-Object System.IO.DriveInfo($root) + if ($driveInfo.IsReady) { + $freeSpaceMB = [Math]::Floor($driveInfo.AvailableFreeSpace / 1MB) + } + } + } + catch { + return [pscustomobject]@{ + Accept = $false + SmtpCode = "451 4.3.0 SMTPGraphRelay storage status unavailable" + Reason = "Freier Speicher konnte nicht ermittelt werden: $($_.Exception.Message)" + PendingCount = $pendingCount + FreeSpaceMB = $null + } + } + + if ($null -ne $freeSpaceMB -and $freeSpaceMB -lt $limits.MinFreeDiskSpaceMB) { + return [pscustomobject]@{ + Accept = $false + SmtpCode = "452 4.3.1 Insufficient system storage" + Reason = "Freier Speicher zu niedrig (${freeSpaceMB}MB < $($limits.MinFreeDiskSpaceMB)MB)" + PendingCount = $pendingCount + FreeSpaceMB = $freeSpaceMB + } + } + + return [pscustomobject]@{ + Accept = $true + SmtpCode = $null + Reason = "OK" + PendingCount = $pendingCount + FreeSpaceMB = $freeSpaceMB + } +} + function Save-SmtpMessage { param( [Parameter(Mandatory)] @@ -743,23 +931,34 @@ function Save-SmtpMessage { [string[]]$Recipients, [Parameter(Mandatory)] - [string]$RemoteAddress + [string]$RemoteAddress, + + [string]$QueueId ) $dirs = Get-QueueDirectories - $id = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmssfff"), ([guid]::NewGuid().ToString("N").Substring(0,8)) - $incomingEmlTmp = Join-Path $dirs.Incoming "$id.eml.tmp" - $incomingMetaTmp = Join-Path $dirs.Incoming "$id.eml.json.tmp" + if ([string]::IsNullOrWhiteSpace($QueueId)) { + $QueueId = New-QueueId + } - $pendingEml = Join-Path $dirs.Pending "$id.eml" + $incomingEmlTmp = Join-Path $dirs.Incoming "$QueueId.eml.tmp" + $incomingMetaTmp = Join-Path $dirs.Incoming "$QueueId.eml.json.tmp" + + $pendingEml = Join-Path $dirs.Pending "$QueueId.eml" $pendingMeta = "$pendingEml.json" + $messageLines = Add-RelayMessageHeaders ` + -Lines $Lines ` + -QueueId $QueueId ` + -RemoteAddress $RemoteAddress + # SMTP DATA wird in CRLF normalisiert. - $raw = ($Lines -join "`r`n") + "`r`n" + $raw = ($messageLines -join "`r`n") + "`r`n" $utf8NoBom = New-Object System.Text.UTF8Encoding($false) $meta = [ordered]@{ + QueueId = $QueueId ReceivedUtc = [DateTime]::UtcNow.ToString("o") RemoteAddress = $RemoteAddress EnvelopeFrom = $MailFrom @@ -786,8 +985,11 @@ function Save-SmtpMessage { throw } - Write-Log "Mail angenommen: $id | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress" - return $pendingEml + Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress" + return [pscustomobject]@{ + QueueId = $QueueId + Path = $pendingEml + } } function Write-SmtpLine { @@ -828,6 +1030,8 @@ function Handle-SmtpClient { $mailFrom = $null $recipients = New-Object System.Collections.Generic.List[string] + $acceptedMessages = 0 + $limits = Get-SmtpLimits Write-SmtpLine $writer ("220 {0} SMTPGraphRelay ready" -f $script:Config.Smtp.Hostname) @@ -842,6 +1046,12 @@ function Handle-SmtpClient { Write-SmtpLine $writer "250 8BITMIME" } elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') { + if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) { + Write-SmtpLine $writer "452 4.5.3 Too many messages in this session" + Write-Log ("SMTP-Session von {0}: Nachrichtenlimit {1} erreicht." -f $remoteIp, $limits.MaxMessagesPerConnection) "WARN" + continue + } + $mailFrom = $matches[1] $recipients.Clear() Write-SmtpLine $writer "250 2.1.0 OK" @@ -851,6 +1061,13 @@ function Handle-SmtpClient { Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM first" continue } + + if ($recipients.Count -ge $limits.MaxRecipients) { + Write-SmtpLine $writer "452 4.5.3 Too many recipients" + Write-Log ("SMTP-Session von {0}: Empfängerlimit {1} erreicht." -f $remoteIp, $limits.MaxRecipients) "WARN" + continue + } + $recipients.Add($matches[1]) Write-SmtpLine $writer "250 2.1.5 OK" } @@ -860,6 +1077,20 @@ function Handle-SmtpClient { continue } + if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) { + Write-SmtpLine $writer "452 4.5.3 Too many messages in this session" + continue + } + + # Backpressure VOR 354/DATA: Der Client soll große Nachrichtendaten + # gar nicht erst übertragen, wenn wir sie nicht sicher puffern können. + $pressure = Get-QueuePressure + if (-not $pressure.Accept) { + Write-SmtpLine $writer $pressure.SmtpCode + Write-Log ("SMTP-Backpressure für {0}: {1}" -f $remoteIp, $pressure.Reason) "WARN" + continue + } + Write-SmtpLine $writer "354 End data with ." $data = New-Object System.Collections.Generic.List[string] $size = 0 @@ -877,7 +1108,8 @@ function Handle-SmtpClient { $size += [System.Text.Encoding]::UTF8.GetByteCount($dataLine) + 2 if ($size -gt $maxBytes) { $tooLarge = $true - } elseif (-not $tooLarge) { + } + elseif (-not $tooLarge) { $data.Add($dataLine) } } @@ -885,9 +1117,28 @@ function Handle-SmtpClient { if ($tooLarge) { Write-SmtpLine $writer "552 5.3.4 Message size exceeds fixed maximum message size" Write-Log "Mail von $remoteIp wegen Größenlimit verworfen." "WARN" - } else { - [void](Save-SmtpMessage -Lines $data -MailFrom $mailFrom -Recipients $recipients.ToArray() -RemoteAddress $remoteIp.ToString()) - Write-SmtpLine $writer "250 2.0.0 Queued" + } + else { + $queueId = New-QueueId + + try { + $saved = Save-SmtpMessage ` + -Lines $data ` + -MailFrom $mailFrom ` + -Recipients $recipients.ToArray() ` + -RemoteAddress $remoteIp.ToString() ` + -QueueId $queueId + + $acceptedMessages++ + Write-SmtpLine $writer ("250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId) + } + catch { + # Nach DATA darf niemals 250 gesendet werden, wenn die Queue-Datei + # nicht vollständig und atomar gesichert werden konnte. + Write-SmtpLine $writer "451 4.3.0 SMTPGraphRelay queue temporarily unavailable" + Write-Log ("[{0}] Queue-Speicherung fehlgeschlagen | Client={1} | Fehler={2}" -f ` + $queueId, $remoteIp, $_.Exception.Message) "ERROR" + } } $mailFrom = $null @@ -924,7 +1175,6 @@ function Handle-SmtpClient { } } - function Get-RelayCertificateStatus { $thumbprint = [string]$script:Config.Graph.CertificateThumbprint @@ -1172,6 +1422,10 @@ $smtpFunctionNames = @( "Test-IPv4InCidr", "Test-ClientAllowed", "Get-QueueDirectories", + "New-QueueId", + "Add-RelayMessageHeaders", + "Get-SmtpLimits", + "Get-QueuePressure", "Save-SmtpMessage", "Write-SmtpLine", "Handle-SmtpClient" @@ -1251,11 +1505,17 @@ $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.5 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" +Write-Log "SMTPGraphRelay V1.6 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." +$limits = Get-SmtpLimits +Write-Log ("SMTP-Limits: max. {0} Empfänger/Mail, {1} Mails/Verbindung." -f ` + $limits.MaxRecipients, $limits.MaxMessagesPerConnection) +Write-Log ("Backpressure: max. {0} Pending-Mails, mindestens {1} MB freier Speicher." -f ` + $limits.MaxPendingMessages, $limits.MinFreeDiskSpaceMB) + $logSettings = Get-LogSettings Write-Log ("Log-Rotation: max. {0} MB pro Datei, Aufbewahrung {1} Tage." -f ` $logSettings.MaxFileSizeMB, $logSettings.RetentionDays)