From 590e4414759d3358d68e0ef0e5fc4e568890c401 Mon Sep 17 00:00:00 2001 From: "manuel.maier" Date: Tue, 3 Nov 2026 16:33:43 +0100 Subject: [PATCH] SMTPGraphRelay.ps1 aktualisiert --- SMTPGraphRelay.ps1 | 1893 +++++++++++++------------------------------- 1 file changed, 533 insertions(+), 1360 deletions(-) diff --git a/SMTPGraphRelay.ps1 b/SMTPGraphRelay.ps1 index 6cc86d5..a76530f 100644 --- a/SMTPGraphRelay.ps1 +++ b/SMTPGraphRelay.ps1 @@ -1,1479 +1,652 @@ -[CmdletBinding()] -param([string]$ConfigPath = "$PSScriptRoot\config.json"); +[CmdletBinding()] +param( + [string]$ConfigPath = "$PSScriptRoot\config.json" +) #Requires -Version 5.1 +#Requires -RunAsAdministrator -$ErrorActionPreference = "Stop"; -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; - -function Get-LogSettings { - $maxSizeMB = 10; - $retentionDays = 30; - $cleanupHours = 12; - - try { - if ($script:Config.Logging) { - if ($script:Config.Logging.PSObject.Properties.Name -contains "MaxFileSizeMB") { - $v = [int]$script:Config.Logging.MaxFileSizeMB; - if ($v -ge 1 -and $v -le 1024) { $maxSizeMB = $v; } - } - if ($script:Config.Logging.PSObject.Properties.Name -contains "RetentionDays") { - $v = [int]$script:Config.Logging.RetentionDays; - if ($v -ge 1 -and $v -le 3650) { $retentionDays = $v; } - } - if ($script:Config.Logging.PSObject.Properties.Name -contains "CleanupHours") { - $v = [int]$script:Config.Logging.CleanupHours; - if ($v -ge 1 -and $v -le 168) { $cleanupHours = $v; } - } - } - } catch {} - - return [pscustomobject]@{ - MaxFileSizeMB = $maxSizeMB; - RetentionDays = $retentionDays; - CleanupHours = $cleanupHours; - }; -} - -function Invoke-LogMaintenance { - param([switch]$Force); - - if (-not $script:Config -or -not $script:Config.Paths.Logs) { return; } - - $settings = Get-LogSettings; - $logDir = $script:Config.Paths.Logs; - - if (-not [IO.Path]::IsPathRooted($logDir)) { - if ($PSScriptRoot) { - $logDir = Join-Path ($PSScriptRoot) $logDir; - } else { - return; - } - } - - New-Item -ItemType Directory -Path ($logDir) -Force | Out-Null; - - if (-not $script:NextLogCleanup) { - $script:NextLogCleanup = [DateTime]::MinValue; - } - - if (-not $Force -and (Get-Date) -lt $script:NextLogCleanup) { return; } - - $cutoff = (Get-Date).AddDays(-1 * $settings.RetentionDays); - $files = Get-ChildItem -LiteralPath ($logDir) -File -Filter "SMTPGraphRelay-*.log" -ErrorAction SilentlyContinue; - - foreach ($file in $files) { - if ($file.LastWriteTime -lt $cutoff) { - try { Remove-Item -LiteralPath ($file.FullName) -Force -ErrorAction Stop; } catch {} - } - } - - $script:NextLogCleanup = (Get-Date).AddHours($settings.CleanupHours); -} - -function Rotate-LogIfNeeded { - param([Parameter(Mandatory)][string]$LogFile); - - if (-not (Test-Path -LiteralPath ($LogFile))) { return; } - - $settings = Get-LogSettings; - - try { - $file = Get-Item -LiteralPath ($LogFile) -ErrorAction Stop; - $maxBytes = [int64]$settings.MaxFileSizeMB * 1024 * 1024; - - if ($file.Length -lt $maxBytes) { return; } - - $stamp = Get-Date -Format "yyyyMMdd-HHmmss"; - $dir = Split-Path ($LogFile) -Parent; - $archive = Join-Path ($dir) "SMTPGraphRelay-$stamp.log"; - - $counter = 1; - while (Test-Path -LiteralPath ($archive)) { - $archive = Join-Path ($dir) ("SMTPGraphRelay-{0}-{1}.log" -f $stamp, $counter); - $counter++; - } - - Move-Item -LiteralPath ($LogFile) -Destination ($archive) -Force; - } catch {} -} +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 function Write-Log { - param( - [Parameter(Mandatory)][string]$Message, - [ValidateSet("INFO","WARN","ERROR","DEBUG")][string]$Level = "INFO" - ); - - $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"; - $line = "[$ts] [$Level] $Message"; - Write-Host $line; - + param([string]$Message, [string]$Level = "INFO") + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" + $line = "[$timestamp] [$Level] $Message" + Write-Host $line + try { - if ($script:Config -and $script:Config.Paths.Logs) { - $logDir = $script:Config.Paths.Logs; - - if (-not [IO.Path]::IsPathRooted($logDir)) { - if ($PSScriptRoot) { - $logDir = Join-Path ($PSScriptRoot) $logDir; - } else { - return; - } - } - - New-Item -ItemType Directory -Path ($logDir) -Force | Out-Null; - $logFile = Join-Path ($logDir) "SMTPGraphRelay.log"; - - $mutex = New-Object System.Threading.Mutex($false, "Local\SMTPGraphRelay-Log"); - $lockTaken = $false; - - try { - $lockTaken = $mutex.WaitOne(5000); - if ($lockTaken) { - Rotate-LogIfNeeded -LogFile ($logFile); - Add-Content -LiteralPath ($logFile) -Value ($line) -Encoding UTF8; - Invoke-LogMaintenance; - } - } finally { - if ($lockTaken) { try { $mutex.ReleaseMutex(); } catch {} } - $mutex.Dispose(); - } + if ($script:Config -and $script:Config.Paths -and $script:Config.Paths.Logs) { + $logDir = Resolve-PathFromConfig $script:Config.Paths.Logs + if (-not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + $logFile = Join-Path $logDir ("relay-{0}.log" -f (Get-Date -Format "yyyyMMdd")) + Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue } } catch {} } function Resolve-PathFromConfig { - param([Parameter(Mandatory)][string]$Path); - if ([IO.Path]::IsPathRooted($Path)) { return $Path; } - return (Join-Path ($PSScriptRoot) $Path); -} - -function Test-IPv4InCidr { - param( - [Parameter(Mandatory)][System.Net.IPAddress]$Address, - [Parameter(Mandatory)][string]$Cidr - ); - - if ($Address.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) { return $false; } - if ($Cidr -notmatch '^(.+)/(\d{1,2})$') { return $false; } - - try { $network = [System.Net.IPAddress]::Parse($matches[1]); } catch { return $false; } - if ($network.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) { return $false; } - - $prefix = [int]$matches[2]; - if ($prefix -lt 0 -or $prefix -gt 32) { return $false; } - - $ipBytes = $Address.GetAddressBytes(); - $netBytes = $network.GetAddressBytes(); - - for ($i = 0; $i -lt 4; $i++) { - $remaining = $prefix - ($i * 8); - if ($remaining -le 0) { break; } - - $bits = [Math]::Min(8, $remaining); - [byte]$mask = (0xFF -shl (8 - $bits)) -band 0xFF; - - if (($ipBytes[$i] -band $mask) -ne ($netBytes[$i] -band $mask)) { return $false; } - } - return $true; -} - -function Test-ClientAllowed { - param([Parameter(Mandatory)][System.Net.IPAddress]$Address); - - $allowed = @($script:Config.Smtp.AllowedNetworks); - foreach ($entry in $allowed) { - if ($entry -eq "*") { return $true; } - try { - if ($entry -match '/') { - if (Test-IPv4InCidr -Address ($Address) -Cidr ($entry)) { return $true; } - } elseif ([System.Net.IPAddress]::Parse($entry).Equals($Address)) { - return $true; - } - } catch {} - } - return $false; -} - -function Get-GraphConnection { - if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) { - throw "Microsoft.Graph.Authentication ist nicht installiert."; - } - - Import-Module Microsoft.Graph.Authentication -ErrorAction Stop; - $cert = Get-Item -LiteralPath ("Cert:\LocalMachine\My\{0}" -f $script:Config.Graph.CertificateThumbprint) -ErrorAction Stop; - - $ctx = Get-MgContext; - $needsConnect = $true; - - if ($ctx) { - if ($ctx.ClientId -eq $script:Config.Graph.ClientId -and $ctx.TenantId -eq $script:Config.Graph.TenantId -and $ctx.AuthType -eq "AppOnly") { - $needsConnect = $false; - } - } - - if ($needsConnect) { - Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null; - Connect-MgGraph -TenantId ($script:Config.Graph.TenantId) -ClientId ($script:Config.Graph.ClientId) -Certificate ($cert) -NoWelcome | Out-Null; - } -} - -function Set-MimeSender { - param( - [Parameter(Mandatory)][byte[]]$MimeBytes, - [Parameter(Mandatory)][string]$Sender - ); - - $latin1 = [System.Text.Encoding]::GetEncoding(28591); - $text = $latin1.GetString($MimeBytes); - $separator = "`r`n`r`n"; - $idx = $text.IndexOf($separator); - - if ($idx -lt 0) { - $separator = "`n`n"; - $idx = $text.IndexOf($separator); - } - if ($idx -lt 0) { return $MimeBytes; } - - $headers = $text.Substring(0, $idx); - $body = $text.Substring($idx + $separator.Length); - - if ($headers -match '(?im)^From:.*(?:\r?\n[ \t].*)*') { - $headers = [regex]::Replace($headers, '(?im)^From:.*(?:\r?\n[ \t].*)*', "From: <$Sender>", 1); - } else { - $headers = "From: <$Sender>`r`n" + $headers; - } - - return $latin1.GetBytes($headers + "`r`n`r`n" + $body); + param([string]$Path) + if ([IO.Path]::IsPathRooted($Path)) { return $Path } + return (Join-Path $PSScriptRoot $Path) } function Get-QueueDirectories { - $queueRoot = Resolve-PathFromConfig ($script:Config.Paths.Queue); - $failedDir = Resolve-PathFromConfig ($script:Config.Paths.Failed); - + $qPath = Resolve-PathFromConfig $script:Config.Paths.Queue 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; - $folderList = @($dirs.Root, $dirs.Incoming, $dirs.Pending, $dirs.Processing, $dirs.Failed); - - foreach ($dir in $folderList) { - New-Item -ItemType Directory -Path ($dir) -Force | Out-Null; - } - - $oldFiles = Get-ChildItem -LiteralPath ($dirs.Root) -Filter "*.eml" -File -ErrorAction SilentlyContinue; - foreach ($file in $oldFiles) { - $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)"; - } - - $procFiles = Get-ChildItem -LiteralPath ($dirs.Processing) -Filter "*.eml" -File -ErrorAction SilentlyContinue; - foreach ($file in $procFiles) { - $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 zurueck 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) { - 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 {} - - 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 {} - - if ($null -eq $statusCode) { - $msgOut = $ErrorRecord | Out-String; - $combined = "$message $msgOut"; - if ($combined -match '(?"; - $result.Add("Message-ID: $messageId"); + if (-not $hasMessageId) { + $safeHost = ($hostname -replace '[^A-Za-z0-9._-]', '-') + $utcNowStr = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss') + $messageId = "<$QueueId.$utcNowStr@$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 Test-AddressInList { - param( - [Parameter(Mandatory)][System.Net.IPAddress]$Address, - [object[]]$Entries - ); - - $list = @($Entries); - foreach ($entry in $list) { - if ($null -eq$entry) { continue; } - $value = [string]$entry; - if ([string]::IsNullOrWhiteSpace($value)) { continue; } - if ($value -eq "*") { return $true; } - - try { - if ($value -match '/') { - if (Test-IPv4InCidr -Address ($Address) -Cidr ($value)) { return$true; } - } elseif ([System.Net.IPAddress]::Parse($value).Equals($Address)) { - return $true; - } - } catch {} - } - return $false; -} - -function Get-SmtpAuthSettings { - $requireAuth =$false; - $maxFailures = 5; - $allowUnauthenticatedNetworks = @();$users = @(); - - try { - if ($script:Config.Smtp.PSObject.Properties.Name -contains "RequireAuth") { $requireAuth = [bool]$script:Config.Smtp.RequireAuth; } - if ($script:Config.Smtp.PSObject.Properties.Name -contains "AuthMaxFailures") { - $v = [int]$script:Config.Smtp.AuthMaxFailures; - if ($v -ge 1 -and$v -le 100) { $maxFailures =$v; } - } - if ($script:Config.Smtp.PSObject.Properties.Name -contains "AllowUnauthenticatedNetworks") { $allowUnauthenticatedNetworks = @($script:Config.Smtp.AllowUnauthenticatedNetworks); } - if ($script:Config.Smtp.PSObject.Properties.Name -contains "AuthUsers") { $users = @($script:Config.Smtp.AuthUsers); } - } catch {} - - return [pscustomobject]@{ RequireAuth = $requireAuth; AuthMaxFailures =$maxFailures; AllowUnauthenticatedNetworks = $allowUnauthenticatedNetworks; Users =$users; }; -} - -function Test-SmtpAuthenticationRequired { - param([Parameter(Mandatory)][System.Net.IPAddress]$Address); - - $settings = Get-SmtpAuthSettings; - if (-not $settings.RequireAuth) { return$false; } - if (Test-AddressInList -Address ($Address) -Entries ($settings.AllowUnauthenticatedNetworks)) { return$false; } - return $true; -} - -function ConvertFrom-Base64Utf8 { - param([Parameter(Mandatory)][string]$Value); - try { - $bytes = [Convert]::FromBase64String($Value); - return [Text.Encoding]::UTF8.GetString($bytes); - } catch { throw "Invalid Base64 data"; } -} - -function Invoke-Pbkdf2Sha256 { - param( - [Parameter(Mandatory)][string]$Password, - [Parameter(Mandatory)][byte[]]$Salt, - [Parameter(Mandatory)][int]$Iterations, - [int]$Length = 32 - ); - - if ($Iterations -lt 1) { throw "Iterations must be greater than zero."; } - - try { - $derive = New-Object System.Security.Cryptography.Rfc2898DeriveBytes($Password, $Salt,$Iterations, [System.Security.Cryptography.HashAlgorithmName]::SHA256); - try { return $derive.GetBytes($Length); } finally {$derive.Dispose(); } - } catch { - $hmac = New-Object System.Security.Cryptography.HMACSHA256; - $hmac.Key = [Text.Encoding]::UTF8.GetBytes($Password); - try { - $hashLength = 32; - $blocks = [Math]::Ceiling($Length / [double]$hashLength); - $output = New-Object byte[] ($blocks * $hashLength);$offset = 0; - - for ($block = 1; $block -le $blocks; $block++) { - $blockBytes = [BitConverter]::GetBytes([int]$block); - if ([BitConverter]::IsLittleEndian) { [Array]::Reverse($blockBytes); } - - $input = New-Object byte[] ($Salt.Length + 4); - [Array]::Copy($Salt, 0, $input, 0,$Salt.Length); - [Array]::Copy($blockBytes, 0, $input,$Salt.Length, 4); - - $u = $hmac.ComputeHash($input); - $t = New-Object byte[]$u.Length; - [Array]::Copy($u, $t,$u.Length); - - for ($i = 2; $i -le$Iterations; $i++) {$u = $hmac.ComputeHash($u); - for ($j = 0; $j -lt $t.Length; $j++) { $t[$j] = $t[$j] -bxor $u[$j]; } - } - - [Array]::Copy($t, 0,$output, $offset,$t.Length); - $offset +=$t.Length; - } - - $result = New-Object byte[]$Length; - [Array]::Copy($output, 0, $result, 0,$Length); - return $result; - } finally { $hmac.Dispose(); } - } -} - -function Test-FixedTimeEquals { - param([Parameter(Mandatory)][byte[]]$A, [Parameter(Mandatory)][byte[]]$B); - if ($A.Length -ne $B.Length) { return$false; } - [int]$difference = 0; - for ($i = 0; $i -lt $A.Length; $i++) { $difference =$difference -bor ($A[$i] -bxor $B[$i]); } - return ($difference -eq 0); -} - -function Test-SmtpCredentials { - param([Parameter(Mandatory)][string]$Username, [Parameter(Mandatory)][string]$Password); - if ([string]::IsNullOrWhiteSpace($Username)) { return $false; }$settings = Get-SmtpAuthSettings; - $user =$settings.Users | Where-Object { ([string]$_.Username).Equals($Username, [StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1; - if (-not $user) { return$false; } - - try { - $salt = [Convert]::FromBase64String([string]$user.Salt); - $expected = [Convert]::FromBase64String([string]$user.PasswordHash); - $iterations = [int]$user.Iterations; - - if ($iterations -lt 10000 -or$expected.Length -lt 16 -or $salt.Length -lt 8) { return$false; } - - $actual = Invoke-Pbkdf2Sha256 -Password ($Password) -Salt ($salt) -Iterations ($iterations) -Length ($expected.Length); - return (Test-FixedTimeEquals -A ($actual) -B ($expected)); - } catch { return $false; } -} - -function Invoke-SmtpAuthLogin { - param([Parameter(Mandatory)][System.IO.StreamReader]$Reader, [Parameter(Mandatory)][System.IO.StreamWriter]$Writer, [string]$InitialResponse); - try { - if ([string]::IsNullOrWhiteSpace($InitialResponse)) { - Write-SmtpLine -Writer ($Writer) -Line "334 VXNlcm5hbWU6"; - $encodedUser =$Reader.ReadLine(); - if ($null -eq$encodedUser) { throw "Client disconnected during AUTH LOGIN"; } - } else { $encodedUser =$InitialResponse; } - - $username = ConvertFrom-Base64Utf8 -Value ($encodedUser); - - Write-SmtpLine -Writer ($Writer) -Line "334 UGFzc3dvcmQ6"; - $encodedPassword =$Reader.ReadLine(); - if ($null -eq$encodedPassword) { throw "Client disconnected during AUTH LOGIN"; } - - $password = ConvertFrom-Base64Utf8 -Value ($encodedPassword);$isValid = Test-SmtpCredentials -Username ($username) -Password ($password); - return [pscustomobject]@{ Valid = $isValid; Username = $username; ProtocolError =$false; }; - } catch { return [pscustomobject]@{ Valid = $false; Username = $null; ProtocolError =$true; }; } -} - -function Invoke-SmtpAuthPlain { - param([Parameter(Mandatory)][System.IO.StreamReader]$Reader, [Parameter(Mandatory)][System.IO.StreamWriter]$Writer, [string]$InitialResponse); - try { - $encoded =$InitialResponse; - if ([string]::IsNullOrWhiteSpace($encoded)) { - Write-SmtpLine -Writer ($Writer) -Line "334"; - $encoded =$Reader.ReadLine(); - if ($null -eq$encoded) { throw "Client disconnected during AUTH PLAIN"; } - } - - $decoded = ConvertFrom-Base64Utf8 -Value ($encoded); - $parts =$decoded -split "`0", 3; - if ($parts.Count -lt 3) { throw "Invalid AUTH PLAIN payload"; } - - $username = $parts[1]; - $password = $parts[2]; - $isValid = Test-SmtpCredentials -Username ($username) -Password ($password); - return [pscustomobject]@{ Valid = $isValid; Username = $username; ProtocolError = $false; }; - } catch { return [pscustomobject]@{ Valid = $false; Username = $null; ProtocolError = $true; }; } -} - -function Get-DebugSettings { - $defaults = [ordered]@{ Enabled = $false; DelayBeforeGraphSendSeconds = 0; SimulateGraphStatus = 0; SimulateQueueWriteFailure = $false; OverridePendingCount = -1; OverrideFreeDiskSpaceMB = -1; }; - try { - if (-not ($script:Config.PSObject.Properties.Name -contains "Debug") -or -not $script:Config.Debug) { return [pscustomobject]$defaults; } - $d = $script:Config.Debug; - if ($d.PSObject.Properties.Name -contains "Enabled") { $defaults.Enabled = [bool]$d.Enabled; } - if ($d.PSObject.Properties.Name -contains "DelayBeforeGraphSendSeconds") { - $v = [int]$d.DelayBeforeGraphSendSeconds; - if ($v -ge 0 -and $v -le 300) { $defaults.DelayBeforeGraphSendSeconds = $v; } - } - if ($d.PSObject.Properties.Name -contains "SimulateGraphStatus") { - $v = [int]$d.SimulateGraphStatus; - if ($v -eq 0 -or ($v -ge 400 -and $v -le 599)) { $defaults.SimulateGraphStatus = $v; } - } - if ($d.PSObject.Properties.Name -contains "SimulateQueueWriteFailure") { $defaults.SimulateQueueWriteFailure = [bool]$d.SimulateQueueWriteFailure; } - if ($d.PSObject.Properties.Name -contains "OverridePendingCount") { - $v = [int]$d.OverridePendingCount; - if ($v -ge -1 -and $v -le 1000000) { $defaults.OverridePendingCount = $v; } - } - if ($d.PSObject.Properties.Name -contains "OverrideFreeDiskSpaceMB") { - $v = [int]$d.OverrideFreeDiskSpaceMB; - if ($v -ge -1 -and $v -le 1048576) { $defaults.OverrideFreeDiskSpaceMB = $v; } - } - } catch {} - return [pscustomobject]$defaults; -} - -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; - $debug = Get-DebugSettings; - - try { - if ($debug.Enabled -and $debug.OverridePendingCount -ge 0) { - $pendingCount = [int]$debug.OverridePendingCount; - Write-Log ("QA: PendingCount wird mit {0} simuliert." -f $pendingCount) "WARN"; - } else { - $pendingFiles = Get-ChildItem -LiteralPath ($dirs.Pending) -Filter "*.eml" -File -ErrorAction Stop; - $pendingCount = @($pendingFiles).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 (-not $hasFrom -and -not [string]::IsNullOrWhiteSpace($MailFrom)) { + $result.Add("From: <$MailFrom>") } - 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; }; + if (-not $hasTo -and @($Recipients).Count -gt 0) { + $toFormatted = ($Recipients | ForEach-Object { "<$_>" }) -join ", " + $result.Add("To: $toFormatted") } - $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) { - if ($debug.Enabled -and $debug.OverrideFreeDiskSpaceMB -ge 0) { - $freeSpaceMB = [int]$debug.OverrideFreeDiskSpaceMB; - Write-Log ("QA: freier Speicher wird mit {0} MB simuliert." -f $freeSpaceMB) "WARN"; - } else { - $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; }; - } + for ($i = 0; $i -lt $Lines.Count; $i++) { $result.Add($Lines[$i]) } + if ($separatorIndex -lt 0) { $result.Add("") } - 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; }; + return $result } function Save-SmtpMessage { param( - [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][System.Collections.Generic.List[string]]$Lines, - [Parameter(Mandatory)][string]$MailFrom, - [Parameter(Mandatory)][string[]]$Recipients, + [Parameter(Mandatory)][System.Collections.Generic.List[string]]$Lines, [Parameter(Mandatory)][string]$RemoteAddress, - [string]$AuthenticatedUser, - [string]$QueueId - ); + [Parameter(Mandatory)][string]$AuthenticatedUser, + [Parameter(Mandatory)][string]$MailFrom, + [Parameter(Mandatory)][string[]]$Recipients + ) - $dirs = Get-QueueDirectories; - if ([string]::IsNullOrWhiteSpace($QueueId)) { $QueueId = New-QueueId; } - - $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); - $raw = ($messageLines -join "`r`n") + "`r`n"; + $dirs = Get-QueueDirectories + $queueId = New-QueueId - $latin1 = [System.Text.Encoding]::GetEncoding(28591); - $meta = [ordered]@{ QueueId = $QueueId; ReceivedUtc = [DateTime]::UtcNow.ToString("o"); RemoteAddress = $RemoteAddress; AuthenticatedUser = $AuthenticatedUser; EnvelopeFrom = $MailFrom; EnvelopeRecipients = @($Recipients); RetryCount = 0; NextAttemptUtc = [DateTime]::UtcNow.ToString("o"); }; + $processedLines = Add-RelayMessageHeaders -Lines $Lines -QueueId $queueId -RemoteAddress $RemoteAddress -MailFrom $MailFrom -Recipients $Recipients + + $meta = [ordered]@{ + QueueId = $queueId + ReceivedUtc = [DateTime]::UtcNow.ToString("o") + RemoteAddress = $RemoteAddress + AuthenticatedUser = $AuthenticatedUser + EnvelopeFrom = $MailFrom + EnvelopeRecipients = $Recipients + RetryCount = 0 + NextAttemptUtc = [DateTime]::UtcNow.ToString("o") + } + + $incomingEml = Join-Path $dirs.Incoming "$queueId.eml" + $incomingMeta = "$incomingEml.json" + $pendingEml = Join-Path $dirs.Pending "$queueId.eml" + $pendingMeta = "$pendingEml.json" try { - $debug = Get-DebugSettings; - if ($debug.Enabled -and $debug.SimulateQueueWriteFailure) { - Write-Log ("[{0}] QA: simulierter Queue-Schreibfehler." -f $QueueId) "WARN"; - throw "QA simulated queue write failure"; - } - [IO.File]::WriteAllText($incomingEmlTmp, $raw, $latin1); - $meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath ($incomingMetaTmp) -Encoding UTF8; - Move-Item -LiteralPath ($incomingMetaTmp) -Destination ($pendingMeta) -Force; - Move-Item -LiteralPath ($incomingEmlTmp) -Destination ($pendingEml) -Force; + $rawBytes = [System.Text.Encoding]::GetEncoding("ISO-8859-1").GetBytes(($processedLines -join "`r`n") + "`r`n") + [IO.File]::WriteAllBytes($incomingEml, $rawBytes) + [IO.File]::WriteAllText($incomingMeta, ($meta | ConvertTo-Json -Depth 5), [System.Text.Encoding]::UTF8) + + Move-Item -LiteralPath $incomingEml -Destination $pendingEml -Force + Move-Item -LiteralPath $incomingMeta -Destination $pendingMeta -Force + + Write-Log "[$queueId] Nachricht erfolgreich in Queue aufgenommen (Absender: $MailFrom, Empfaenger: $(@($Recipients) -join ', '))." + return $true } 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; - } - - $authText = if ([string]::IsNullOrWhiteSpace($AuthenticatedUser)) { "unauthenticated"; } else { $AuthenticatedUser; }; - $rcptStr = $Recipients -join ', '; - Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$rcptStr | Client=$RemoteAddress | Auth=$authText"; - return [pscustomobject]@{ QueueId = $QueueId; Path = $pendingEml; }; -} - -function Write-SmtpLine { - param([Parameter(Mandatory)][System.IO.StreamWriter]$Writer, [Parameter(Mandatory)][string]$Line); - $Writer.WriteLine($Line); - $Writer.Flush(); -} - -function Handle-SmtpClient { - param([Parameter(Mandatory)][System.Net.Sockets.TcpClient]$Client); - - $remote = $Client.Client.RemoteEndPoint; - $remoteIp = ([System.Net.IPEndPoint]$remote).Address; - Write-Log "SMTP-Verbindung von $remoteIp"; - - if (-not (Test-ClientAllowed -Address ($remoteIp))) { - try { - $stream = $Client.GetStream(); - $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII); - $writer.NewLine = "`r`n"; - $writer.AutoFlush = $true; - Write-SmtpLine -Writer ($writer) -Line "554 5.7.1 Client not allowed"; - } catch {} - $Client.Close(); - Write-Log "Client abgewiesen: $remoteIp" "WARN"; - return; - } - - $stream = $Client.GetStream(); - $stream.ReadTimeout = [int]$script:Config.Smtp.ClientTimeoutSeconds * 1000; - $latin1 = [System.Text.Encoding]::GetEncoding(28591); - - $reader = New-Object System.IO.StreamReader($stream, $latin1, $false, 4096, $true); - $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII, 4096, $true); - $writer.NewLine = "`r`n"; - $writer.AutoFlush = $true; - - $mailFrom = $null; - $recipients = New-Object System.Collections.Generic.List[string]; - $acceptedMessages = 0; - $limits = Get-SmtpLimits; - $authSettings = Get-SmtpAuthSettings; - - $authenticated = $false; - $authenticatedUser = $null; - $authFailures = 0; - $authLocked = $false; - $authRequired = Test-SmtpAuthenticationRequired -Address ($remoteIp); - $usersCount = @($authSettings.Users).Count; - $authAvailable = ($usersCount -gt 0); - - Write-SmtpLine -Writer ($writer) -Line ("220 {0} SMTPGraphRelay ready" -f $script:Config.Smtp.Hostname); - - try { - while ($Client.Connected) { - $line = $reader.ReadLine(); - if ($null -eq $line) { break; } - - if ($line -match '^(?i)EHLO\s+(.+)$') { - Write-SmtpLine -Writer ($writer) -Line ("250-{0}" -f $script:Config.Smtp.Hostname); - $sz = [int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024; - Write-SmtpLine -Writer ($writer) -Line ("250-SIZE {0}" -f $sz); - if ($authAvailable) { Write-SmtpLine -Writer ($writer) -Line "250-AUTH LOGIN PLAIN"; } - Write-SmtpLine -Writer ($writer) -Line "250 8BITMIME"; - } - elseif ($line -match '^(?i)HELO\s+(.+)$') { - Write-SmtpLine -Writer ($writer) -Line ("250 {0}" -f $script:Config.Smtp.Hostname); - } - elseif ($line -match '^(?i)AUTH\s+LOGIN(?:\s+(\S+))?\s*$') { - if (-not $authAvailable) { Write-SmtpLine -Writer ($writer) -Line "504 5.7.4 Authentication mechanism unavailable"; continue; } - if ($authenticated) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.0 Already authenticated"; continue; } - if ($authLocked) { Write-SmtpLine -Writer ($writer) -Line "454 4.7.0 Too many authentication failures"; continue; } - - $initial = $matches[1]; - $result = Invoke-SmtpAuthLogin -Reader ($reader) -Writer ($writer) -InitialResponse ($initial); - - if ($result.ProtocolError) { - $authFailures++; - Write-SmtpLine -Writer ($writer) -Line "501 5.5.2 Invalid authentication data"; - } elseif ($result.Valid) { - $authenticated = $true; - $authenticatedUser = $result.Username; - $authFailures = 0; - Write-SmtpLine -Writer ($writer) -Line "235 2.7.0 Authentication successful"; - Write-Log ("SMTP-AUTH LOGIN erfolgreich | Client={0} | Benutzer={1}" -f $remoteIp, $authenticatedUser); - continue; - } else { - $authFailures++; - Write-SmtpLine -Writer ($writer) -Line "535 5.7.8 Authentication credentials invalid"; - Write-Log ("SMTP-AUTH LOGIN fehlgeschlagen | Client={0} | Benutzer={1}" -f $remoteIp, $result.Username) "WARN"; - } - - if ($authFailures -ge $authSettings.AuthMaxFailures) { - $authLocked = $true; - Write-Log ("SMTP-AUTH gesperrt nach {0} Fehlversuchen | Client={1}" -f $authFailures, $remoteIp) "WARN"; - } - } - elseif ($line -match '^(?i)AUTH\s+PLAIN(?:\s+(\S+))?\s*$') { - if (-not $authAvailable) { Write-SmtpLine -Writer ($writer) -Line "504 5.7.4 Authentication mechanism unavailable"; continue; } - if ($authenticated) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.0 Already authenticated"; continue; } - if ($authLocked) { Write-SmtpLine -Writer ($writer) -Line "454 4.7.0 Too many authentication failures"; continue; } - - $initial = $matches[1]; - $result = Invoke-SmtpAuthPlain -Reader ($reader) -Writer ($writer) -InitialResponse ($initial); - - if ($result.ProtocolError) { - $authFailures++; - Write-SmtpLine -Writer ($writer) -Line "501 5.5.2 Invalid authentication data"; - } elseif ($result.Valid) { - $authenticated = $true; - $authenticatedUser = $result.Username; - $authFailures = 0; - Write-SmtpLine -Writer ($writer) -Line "235 2.7.0 Authentication successful"; - Write-Log ("SMTP-AUTH PLAIN erfolgreich | Client={0} | Benutzer={1}" -f $remoteIp, $authenticatedUser); - continue; - } else { - $authFailures++; - Write-SmtpLine -Writer ($writer) -Line "535 5.7.8 Authentication credentials invalid"; - Write-Log ("SMTP-AUTH PLAIN fehlgeschlagen | Client={0} | Benutzer={1}" -f $remoteIp, $result.Username) "WARN"; - } - - if ($authFailures -ge $authSettings.AuthMaxFailures) { - $authLocked = $true; - Write-Log ("SMTP-AUTH gesperrt nach {0} Fehlversuchen | Client={1}" -f $authFailures, $remoteIp) "WARN"; - } - } - elseif ($line -match '^(?i)AUTH\b') { - Write-SmtpLine -Writer ($writer) -Line "504 5.5.4 Unsupported authentication mechanism"; - } - elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') { - if ($authRequired -and -not $authenticated) { Write-SmtpLine -Writer ($writer) -Line "530 5.7.0 Authentication required"; continue; } - if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) { - Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "250 2.1.0 OK"; - } - elseif ($line -match '^(?i)RCPT TO:\s*<([^>]+)>') { - if (-not $mailFrom) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.1 Need MAIL FROM first"; continue; } - if ($recipients.Count -ge $limits.MaxRecipients) { - Write-SmtpLine -Writer ($writer) -Line "452 4.5.3 Too many recipients"; - Write-Log ("SMTP-Session von {0}: Empfaengerlimit {1} erreicht." -f $remoteIp, $limits.MaxRecipients) "WARN"; - continue; - } - $recipients.Add($matches[1]); - Write-SmtpLine -Writer ($writer) -Line "250 2.1.5 OK"; - } - elseif ($line -match '^(?i)DATA\s*$') { - if ($authRequired -and -not $authenticated) { Write-SmtpLine -Writer ($writer) -Line "530 5.7.0 Authentication required"; continue; } - if (-not $mailFrom -or $recipients.Count -eq 0) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.1 Need MAIL FROM and RCPT TO first"; continue; } - if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) { Write-SmtpLine -Writer ($writer) -Line "452 4.5.3 Too many messages in this session"; continue; } - - $pressure = Get-QueuePressure; - if (-not $pressure.Accept) { - Write-SmtpLine -Writer ($writer) -Line ($pressure.SmtpCode); - Write-Log ("SMTP-Backpressure fuer {0}: {1}" -f $remoteIp, $pressure.Reason) "WARN"; - continue; - } - - Write-SmtpLine -Writer ($writer) -Line "354 End data with ."; - - $data = New-Object System.Collections.Generic.List[string]; - $size = 0; - $maxBytes = [int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024; - $tooLarge = $false; - - while ($true) { - $dataLine = $reader.ReadLine(); - if ($null -eq $dataLine) { throw "Client disconnected during DATA"; } - if ($dataLine -eq ".") { break; } - if ($dataLine.StartsWith("..")) { $dataLine = $dataLine.Substring(1); } - - $size += $latin1.GetByteCount($dataLine) + 2; - - if ($size -gt $maxBytes) { $tooLarge = $true; } elseif (-not $tooLarge) { $data.Add($dataLine); } - } - - if ($tooLarge) { - Write-SmtpLine -Writer ($writer) -Line "552 5.3.4 Message size exceeds fixed maximum message size"; - Write-Log "Mail von $remoteIp wegen Groessenlimit verworfen." "WARN"; - } else { - $queueId = New-QueueId; - try { - $rcptArr = $recipients.ToArray(); - $rmtIpStr = $remoteIp.ToString(); - $saved = Save-SmtpMessage -Lines ($data) -MailFrom ($mailFrom) -Recipients ($rcptArr) -RemoteAddress ($rmtIpStr) -AuthenticatedUser ($authenticatedUser) -QueueId ($queueId); - $acceptedMessages++; - Write-SmtpLine -Writer ($writer) -Line ("250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId); - } catch { - Write-SmtpLine -Writer ($writer) -Line "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; - $recipients.Clear(); - } - elseif ($line -match '^(?i)RSET\s*$') { - $mailFrom = $null; - $recipients.Clear(); - Write-SmtpLine -Writer ($writer) -Line "250 2.0.0 Reset"; - } - elseif ($line -match '^(?i)NOOP(?:\s+.*)?$') { Write-SmtpLine -Writer ($writer) -Line "250 2.0.0 OK"; } - elseif ($line -match '^(?i)QUIT\s*$') { Write-SmtpLine -Writer ($writer) -Line "221 2.0.0 Bye"; break; } - elseif ($line -match '^(?i)STARTTLS\b') { Write-SmtpLine -Writer ($writer) -Line "502 5.5.1 Command not implemented"; } - else { Write-SmtpLine -Writer ($writer) -Line "500 5.5.2 Command unrecognized"; } - } - } - catch { Write-Log "SMTP-Clientfehler ${remoteIp}: $($_.Exception.Message)" "WARN"; } - finally { - try { $reader.Dispose(); } catch {} - try { $writer.Dispose(); } catch {} - try { $stream.Dispose(); } catch {} - try { $Client.Close(); } catch {} + Write-Log "[$queueId] Fehler beim Speichern der Nachricht: $($_.Exception.Message)" "ERROR" + return $false } } function Get-RelayCertificateStatus { - $thumbprint = [string]$script:Config.Graph.CertificateThumbprint; - if ([string]::IsNullOrWhiteSpace($thumbprint)) { throw "Graph.CertificateThumbprint fehlt in config.json."; } - - $certPath = "Cert:\LocalMachine\My\$thumbprint"; - $cert = Get-Item -LiteralPath ($certPath) -ErrorAction Stop; - - if (-not $cert.HasPrivateKey) { throw "Relay-Zertifikat '$thumbprint' besitzt keinen privaten Schluessel."; } - - $remaining = $cert.NotAfter.ToUniversalTime() - [DateTime]::UtcNow; + $thumb = [string]$script:Config.Graph.CertificateThumbprint + if ([string]::IsNullOrWhiteSpace($thumb)) { throw "Kein CertificateThumbprint in config.json hinterlegt." } + + $cert = Get-Item -LiteralPath "Cert:\LocalMachine\My\$thumb" -ErrorAction SilentlyContinue + if (-not $cert) { throw "Zertifikat mit Thumbprint $thumb wurde im Cert:\LocalMachine\My nicht gefunden." } + + $now = Get-Date + $expired = $now -gt $cert.NotAfter + $daysRemaining = [int]($cert.NotAfter - $now).TotalDays + if ($daysRemaining -lt 0) { $daysRemaining = 0 } return [pscustomobject]@{ - Certificate = $cert; - Thumbprint = $cert.Thumbprint; - Subject = $cert.Subject; - NotBefore = $cert.NotBefore; - NotAfter = $cert.NotAfter; - DaysRemaining = [Math]::Floor($remaining.TotalDays); - HoursRemaining = [Math]::Floor($remaining.TotalHours); - Expired = ($remaining.TotalSeconds -le 0); - }; + Thumbprint = $thumb + NotAfter = $cert.NotAfter + DaysRemaining = $daysRemaining + Expired = $expired + } } function Send-CertificateAlertMail { - param([Parameter(Mandatory)][int]$DaysRemaining, [Parameter(Mandatory)][string]$Thumbprint); + param([Parameter(Mandatory)][int]$DaysRemaining, [Parameter(Mandatory)][string]$Thumbprint) - $alertMailbox =$null; - if ($script:Config.Graph.PSObject.Properties.Name -contains "AlertMailbox") { $alertMailbox = [string]$script:Config.Graph.AlertMailbox; } - if ([string]::IsNullOrWhiteSpace($alertMailbox)) { return; } + $alertMailbox = $null + if ($script:Config.Graph.PSObject.Properties.Name -contains "AlertMailbox") { $alertMailbox = [string]$script:Config.Graph.AlertMailbox } + if ([string]::IsNullOrWhiteSpace($alertMailbox)) { return } - $dirs = Get-QueueDirectories; - $queueId = New-QueueId; - $subject = "WICHTIG: SMTPGraphRelay Zertifikat laeuft ab ($env:COMPUTERNAME)"; - $body = "Hallo,`r`n`r`ndas Microsoft Graph Zertifikat ($Thumbprint) auf dem Server $env:COMPUTERNAME laeuft in$DaysRemaining Tagen ab.`r`n`r`nBitte das Setup-Skript (Setup-SMTPGraphRelay.ps1) starten und Modus [5] (Zertifikat erneuern) ausfuehren, damit der Mailversand nicht unterbrochen wird.`r`n`r`nViele Gruesse,`r`nSMTPGraphRelay"; + $dirs = Get-QueueDirectories + $queueId = New-QueueId + $hostname = if ([string]::IsNullOrWhiteSpace($script:Config.Smtp.Hostname)) { $env:COMPUTERNAME } else { [string]$script:Config.Smtp.Hostname } + $safeHost = ($hostname -replace '[^A-Za-z0-9._-]', '-') + $dateStr = [DateTimeOffset]::Now.ToString("ddd, dd MMM yyyy HH:mm:ss zzz", [Globalization.CultureInfo]::InvariantCulture) + $msgId = "<$queueId.$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))@$safeHost>" + + $subject = "WICHTIG: SMTPGraphRelay Zertifikat laeuft ab ($hostname)" + $body = "Hallo,`r`n`r`ndas Microsoft Graph Zertifikat ($Thumbprint) auf dem Server $hostname laeuft in $DaysRemaining Tagen ab.`r`n`r`nBitte das Setup-Skript (Setup-SMTPGraphRelay.ps1) starten und Modus [5] (Zertifikat erneuern) ausfuehren, damit der Mailversand nicht unterbrochen wird.`r`n`r`nViele Gruesse,`r`nSMTPGraphRelay" - $lines = New-Object System.Collections.Generic.List[string];$lines.Add("From: <$($script:Config.Graph.SenderMailbox)>"); - $lines.Add("To: <$alertMailbox>"); - $lines.Add("Subject: $subject"); - $lines.Add("MIME-Version: 1.0"); - $lines.Add("Content-Type: text/plain; charset=utf-8"); - $lines.Add(""); - $lines.Add($body); + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add("Date: $dateStr") + $lines.Add("Message-ID: $msgId") + $lines.Add("From: <$($script:Config.Graph.SenderMailbox)>") + $lines.Add("To: <$alertMailbox>") + $lines.Add("Subject: $subject") + $lines.Add("MIME-Version: 1.0") + $lines.Add("Content-Type: text/plain; charset=utf-8") + $lines.Add("") + $lines.Add($body) - $meta = [ordered]@{ QueueId =$queueId; ReceivedUtc = [DateTime]::UtcNow.ToString("o"); RemoteAddress = "127.0.0.1"; AuthenticatedUser = "SystemAlert"; EnvelopeFrom = $script:Config.Graph.SenderMailbox; EnvelopeRecipients = @($alertMailbox); RetryCount = 0; NextAttemptUtc = [DateTime]::UtcNow.ToString("o"); }; + $meta = [ordered]@{ + QueueId = $queueId + ReceivedUtc = [DateTime]::UtcNow.ToString("o") + RemoteAddress = "127.0.0.1" + AuthenticatedUser = "SystemAlert" + EnvelopeFrom = $script:Config.Graph.SenderMailbox + EnvelopeRecipients = @($alertMailbox) + RetryCount = 0 + NextAttemptUtc = [DateTime]::UtcNow.ToString("o") + } - $pendingEml = Join-Path ($dirs.Pending) "$queueId.eml"; - $pendingMeta = "$pendingEml.json"; + $pendingEml = Join-Path $dirs.Pending "$queueId.eml" + $pendingMeta = "$pendingEml.json" try { - $raw = ($lines -join "`r`n") + "`r`n"; - [IO.File]::WriteAllText($pendingEml,$raw, [System.Text.Encoding]::UTF8); - $meta | ConvertTo-Json -Depth 5 \vert{} Set-Content -LiteralPath ($pendingMeta) -Encoding UTF8; - Write-Log "Zertifikats-Warnmail an $alertMailbox in die Queue gelegt (QueueID:$queueId)." "INFO"; - } catch { Write-Log "Fehler beim Erstellen der Warnmail: $($_.Exception.Message)" "ERROR"; } + $raw = ($lines -join "`r`n") + "`r`n" + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [IO.File]::WriteAllText($pendingEml, $raw, $utf8NoBom) + [IO.File]::WriteAllText($pendingMeta, ($meta | ConvertTo-Json -Depth 5), $utf8NoBom) + Write-Log "Zertifikats-Warnmail an $alertMailbox in die Queue gelegt (QueueID: $queueId)." "INFO" + } catch { Write-Log "Fehler beim Erstellen der Warnmail: $($_.Exception.Message)" "ERROR" } } function Test-RelayCertificateExpiry { - param([switch]$ForceLog); + param([switch]$ForceLog) try { - $status = Get-RelayCertificateStatus; - $warningDays = 60; - $criticalDays = 14; + $status = Get-RelayCertificateStatus + $warningDays = 60 + $criticalDays = 14 - if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateWarningDays") { try { $warningDays = [int]$script:Config.Graph.CertificateWarningDays; } catch {} } - if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateCriticalDays") { try { $criticalDays = [int]$script:Config.Graph.CertificateCriticalDays; } catch {} } + if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateWarningDays") { try { $warningDays = [int]$script:Config.Graph.CertificateWarningDays } catch {} } + if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateCriticalDays") { try { $criticalDays = [int]$script:Config.Graph.CertificateCriticalDays } catch {} } if ($status.Expired) { - Write-Log ("KRITISCH: Graph-Zertifikat {0} ist seit {1} abgelaufen!" -f $status.Thumbprint, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "ERROR"; - } elseif ($status.DaysRemaining -le$criticalDays) { - Write-Log ("KRITISCH: Graph-Zertifikat laeuft in {0} Tagen ab ({1}). Bitte Zertifikat erneuern." -f $status.DaysRemaining, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "ERROR"; - } elseif ($status.DaysRemaining -le$warningDays) { - Write-Log ("WARNUNG: Graph-Zertifikat laeuft in {0} Tagen ab ({1}). Zertifikatsrotation einplanen." -f $status.DaysRemaining, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "WARN"; + Write-Log ("KRITISCH: Graph-Zertifikat {0} ist seit {1} abgelaufen!" -f $status.Thumbprint, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "ERROR" + } elseif ($status.DaysRemaining -le $criticalDays) { + Write-Log ("KRITISCH: Graph-Zertifikat laeuft in {0} Tagen ab ({1}). Bitte Zertifikat erneuern." -f $status.DaysRemaining, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "ERROR" + } elseif ($status.DaysRemaining -le $warningDays) { + Write-Log ("WARNUNG: Graph-Zertifikat laeuft in {0} Tagen ab ({1}). Zertifikatsrotation einplanen." -f $status.DaysRemaining, $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss")) "WARN" } elseif ($ForceLog) { - Write-Log ("Graph-Zertifikat gueltig bis {0} ({1} Tage verbleibend)." -f $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss"), $status.DaysRemaining); + Write-Log ("Graph-Zertifikat gueltig bis {0} ({1} Tage verbleibend)." -f $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss"), $status.DaysRemaining) } - if ($status.DaysRemaining -le $warningDays -and -not$status.Expired) { - if (-not $script:LastCertAlert) {$script:LastCertAlert = [DateTime]::MinValue; } - if ((Get-Date) -gt $script:LastCertAlert.AddDays(1)) { - Send-CertificateAlertMail -DaysRemaining ($status.DaysRemaining) -Thumbprint ($status.Thumbprint);$script:LastCertAlert = Get-Date; + if ($status.DaysRemaining -le $warningDays -and -not $status.Expired) { + $logDir = Resolve-PathFromConfig $script:Config.Paths.Logs + $alertStateFile = Join-Path $logDir "last_cert_alert.txt" + $lastAlert = [DateTime]::MinValue + + if (Test-Path -LiteralPath $alertStateFile) { + try { + $rawDate = [IO.File]::ReadAllText($alertStateFile).Trim() + $lastAlert = [DateTime]::Parse($rawDate) + } catch {} + } + + if ((Get-Date) -gt $lastAlert.AddHours(24)) { + Send-CertificateAlertMail -DaysRemaining $status.DaysRemaining -Thumbprint $status.Thumbprint + try { [IO.File]::WriteAllText($alertStateFile, (Get-Date).ToString("o")) } catch {} } } - return $status; - } catch { Write-Log ("Zertifikatspruefung fehlgeschlagen: {0}" -f $_.Exception.Message) "ERROR"; return $null; } + return $status + } catch { Write-Log ("Zertifikatspruefung fehlgeschlagen: {0}" -f $_.Exception.Message) "ERROR"; return $null } } -function Get-FunctionBootstrap { - param([Parameter(Mandatory)][string[]]$FunctionNames); - $parts = New-Object System.Collections.Generic.List[string]; - - foreach ($name in $FunctionNames) { - $item = Get-Item -LiteralPath ("Function:\{0}" -f $name) -ErrorAction Stop; - $parts.Add(("function {0} {{`r`n{1}`r`n}}" -f $name, $item.Definition)); +function Get-GraphConnection { + Import-Module Microsoft.Graph.Authentication -Force -ErrorAction Stop + Import-Module Microsoft.Graph.Users.Actions -Force -ErrorAction Stop + + $ctx = Get-MgContext -ErrorAction SilentlyContinue + if (-not $ctx -or $ctx.AuthType -ne "AppOnly" -or $ctx.ClientId -ne $script:Config.Graph.ClientId) { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + $tenantId = [string]$script:Config.Graph.TenantId + $clientId = [string]$script:Config.Graph.ClientId + $thumb = [string]$script:Config.Graph.CertificateThumbprint + + $cert = Get-Item -LiteralPath "Cert:\LocalMachine\My\$thumb" + Connect-MgGraph -TenantId $tenantId -ClientId $clientId -Certificate $cert -NoWelcome | Out-Null + Write-Log "Microsoft Graph App-only Verbindung erfolgreich aufgebaut (Client: $clientId)." } - return ($parts -join "`r`n`r`n"); } -function New-WorkerConfig { - $copy = $script:Config | ConvertTo-Json -Depth 20 | ConvertFrom-Json; - $copy.Paths.Queue = Resolve-PathFromConfig ($script:Config.Paths.Queue); - $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; -} - -function Send-ServiceBusyAndClose { - param([Parameter(Mandatory)][System.Net.Sockets.TcpClient]$Client); - try { - $stream = $Client.GetStream(); - $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII, 1024, $true); - $writer.NewLine = "`r`n"; - $writer.AutoFlush = $true; - $writer.WriteLine("421 4.3.2 SMTPGraphRelay busy, try again later"); - $writer.Flush(); - $writer.Dispose(); - $stream.Dispose(); - } catch {} finally { try { $Client.Close(); } catch {} } -} - -function Remove-CompletedSmtpWorkers { - for ($i = $script:ActiveSmtpWorkers.Count - 1; $i -ge 0; $i--) { - $worker = $script:ActiveSmtpWorkers[$i]; - if ($worker.AsyncResult.IsCompleted) { - try { [void]$worker.PowerShell.EndInvoke($worker.AsyncResult); } - catch { Write-Log ("SMTP-Worker fuer {0} wurde mit Fehler beendet: {1}" -f $worker.RemoteAddress, $_.Exception.Message) "WARN"; } - finally { try { $worker.PowerShell.Dispose(); } catch {} $script:ActiveSmtpWorkers.RemoveAt($i); } +function Set-MimeSender { + param([byte[]]$MimeBytes, [string]$Sender) + $text = [System.Text.Encoding]::GetEncoding("ISO-8859-1")->GetString($MimeBytes) + $lines = $text -split "`r`n" + $replaced = $false + + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -eq "") { break } + if ($lines[$i] -match '^(?i)From\s*:\s*(.*)$') { + $lines[$i] = "From: <$Sender>" + $replaced = $true + break } } + + if (-not $replaced) { + $lines = @("From: <$Sender>") + $lines + } + + return [System.Text.Encoding]::GetEncoding("ISO-8859-1")->GetBytes(($lines -join "`r`n")) } -function Start-SmtpClientWorker { - param([Parameter(Mandatory)][System.Net.Sockets.TcpClient]$Client); - Remove-CompletedSmtpWorkers; +function Send-QueuedMail { + param([string]$FilePath, [pscustomobject]$meta) + + $queueId = if ($meta -and $meta.QueueId) { $meta.QueueId } else { [IO.Path]::GetFileNameWithoutExtension($FilePath) } + + if (-not (Test-Path -LiteralPath $FilePath)) { return $true } + + $bytes = [IO.File]::ReadAllBytes($FilePath) + $effectiveSender = [string]$script:Config.Graph.SenderMailbox - $remoteAddress = "unknown"; - try { $remoteAddress = ([System.Net.IPEndPoint]$Client.Client.RemoteEndPoint).Address.ToString(); } catch {} - - if ($script:ActiveSmtpWorkers.Count -ge $script:MaxConcurrentClients) { - Write-Log ("SMTP-Verbindung von {0} abgewiesen: Parallel-Limit {1} erreicht." -f $remoteAddress, $script:MaxConcurrentClients) "WARN"; - Send-ServiceBusyAndClose -Client ($Client); - return; + if (-not $script:Config.Graph.ForceSender) { + if ($meta -and $meta.PSObject.Properties.Name -contains "EnvelopeFrom") { + $envFrom = [string]$meta.EnvelopeFrom + if (-not [string]::IsNullOrWhiteSpace($envFrom) -and $envFrom -match '^[^@\s]+@[^@\s]+\.[^@\s]+$') { + $effectiveSender = $envFrom + } + } } - - $ps = [System.Management.Automation.PowerShell]::Create(); - $ps.RunspacePool = $script:SmtpRunspacePool; - - [void]$ps.AddScript($script:SmtpWorkerScript); - [void]$ps.AddArgument($Client); - [void]$ps.AddArgument($script:WorkerConfig); + $bytes = Set-MimeSender -MimeBytes $bytes -Sender $effectiveSender try { - $async = $ps.BeginInvoke(); - [void]$script:ActiveSmtpWorkers.Add([pscustomobject]@{ PowerShell = $ps; AsyncResult = $async; Client = $Client; RemoteAddress = $remoteAddress; StartedUtc = [DateTime]::UtcNow; }); + Get-GraphConnection + $senderEnc = [Uri]::EscapeDataString($effectiveSender) + $uri = "https://graph.microsoft.com/v1.0/users/$senderEnc/sendMail" + $base64 = [Convert]::ToBase64String($bytes) + + Invoke-MgGraphRequest -Method POST -Uri $uri -Body $base64 -ContentType "text/plain" -OutputType PSObject | Out-Null + Write-Log "[$queueId] Mail erfolgreich ueber Graph versendet (Postfach: $effectiveSender)." + return $true } catch { - try { $ps.Dispose(); } catch {} - try { $Client.Close(); } catch {} - throw; + $ex = $_.Exception + $statusCode = 0 + $retryAfterSec = 0 + $errMessage = $ex.Message + + if ($ex -is [Microsoft.Graph.PowerShell.Authentication.ApiException]) { + $statusCode = [int]$ex.Response.StatusCode + } + + Write-Log "[$queueId] Graph-Fehler (HTTP $statusCode): $errMessage" "ERROR" + + $decision = Get-RetryDecision -Exception $ex -Meta $meta + Move-ToFailedOrRetry -FilePath $FilePath -Meta $meta -Decision $decision -LastError $errMessage + return $false } } -if (-not (Test-Path -LiteralPath ($ConfigPath))) { - throw "Konfiguration nicht gefunden: $ConfigPath. Bitte zuerst Setup-SMTPGraphRelay.ps1 ausfuehren."; -} - -$script:Config = Get-Content -LiteralPath ($ConfigPath) -Raw -Encoding UTF8 | ConvertFrom-Json; -$fullConfPath = [IO.Path]::GetFullPath($ConfigPath); -$script:InstallRoot = Split-Path -Parent ($fullConfPath); -$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 {} -} - -Remove-Item -LiteralPath ($script:ShutdownSignalPath) -Force -ErrorAction SilentlyContinue; -$logResPath = Resolve-PathFromConfig ($script:Config.Paths.Logs); -New-Item -ItemType Directory -Path ($logResPath) -Force | Out-Null; -Initialize-QueueDirectories; -Invoke-LogMaintenance -Force; -[void](Test-RelayCertificateExpiry -ForceLog); - -$script:CertificateCheckHours = 12; -if ($script:Config.Graph.PSObject.Properties.Name -contains "CertificateCheckHours") { - try { - $configuredHours = [int]$script:Config.Graph.CertificateCheckHours; - if ($configuredHours -ge 1 -and $configuredHours -le 168) { $script:CertificateCheckHours = $configuredHours; } - } catch {} -} -$script:NextCertificateCheck = (Get-Date).AddHours($script:CertificateCheckHours); - -$script:MaxConcurrentClients = 20; -if ($script:Config.Smtp.PSObject.Properties.Name -contains "MaxConcurrentClients") { - try { - $configuredMax = [int]$script:Config.Smtp.MaxConcurrentClients; - if ($configuredMax -ge 1 -and $configuredMax -le 200) { $script:MaxConcurrentClients = $configuredMax; } else { Write-Log "Ungueltiges Smtp.MaxConcurrentClients; verwende Standard 20." "WARN"; } - } catch { Write-Log "Smtp.MaxConcurrentClients konnte nicht gelesen werden; verwende Standard 20." "WARN"; } -} - -$script:WorkerConfig = New-WorkerConfig; - -$smtpFunctionNames = @("Get-LogSettings", "Invoke-LogMaintenance", "Rotate-LogIfNeeded", "Write-Log", "Resolve-PathFromConfig", "Test-IPv4InCidr", "Test-ClientAllowed", "Get-QueueDirectories", "New-QueueId", "Add-RelayMessageHeaders", "Test-AddressInList", "Get-SmtpAuthSettings", "Test-SmtpAuthenticationRequired", "ConvertFrom-Base64Utf8", "Invoke-Pbkdf2Sha256", "Test-FixedTimeEquals", "Test-SmtpCredentials", "Invoke-SmtpAuthLogin", "Invoke-SmtpAuthPlain", "Get-DebugSettings", "Get-SmtpLimits", "Get-QueuePressure", "Save-SmtpMessage", "Write-SmtpLine", "Handle-SmtpClient"); -$smtpBootstrap = Get-FunctionBootstrap -FunctionNames ($smtpFunctionNames); -$script:SmtpWorkerScript = @" -param(`$Client, `$Config); -`$script:Config = `$Config; -$smtpBootstrap -Handle-SmtpClient -Client (`$Client); -"@; - -$script:SmtpRunspacePool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1,$script:MaxConcurrentClients); -$script:SmtpRunspacePool.Open();$script:ActiveSmtpWorkers = New-Object System.Collections.ArrayList; - -$queueFunctionNames = @("Get-LogSettings", "Invoke-LogMaintenance", "Rotate-LogIfNeeded", "Write-Log", "Resolve-PathFromConfig", "Get-GraphConnection", "Set-MimeSender", "Get-QueueDirectories", "Get-GraphFailureInfo", "Get-RetryDecision", "Get-DebugSettings", "Move-QueueItem", "Send-QueuedMail", "Process-Queue"); -$queueBootstrap = Get-FunctionBootstrap -FunctionNames ($queueFunctionNames);$queueWorkerScript = @" -param(`$Config); -`$script:Config = `$Config; -$queueBootstrap -try { - while (`$true) { - if (`$script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath") { - `$sigPathStr = [string]`$script:Config.ShutdownSignalPath; - if (Test-Path -LiteralPath (`$sigPathStr)) { Write-Log "Queue-Worker: Shutdown-Signal erkannt."; break; } - } - try { Process-Queue; } catch { Write-Log ("Queue-Worker: {0}" -f `$_.Exception.Message) "ERROR"; } - `$sleepUntil = (Get-Date).AddSeconds([int]`$script:Config.Queue.PollSeconds); - while ((Get-Date) -lt `$sleepUntil) { - if (`$script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath") { - `$sigPathStr = [string]`$script:Config.ShutdownSignalPath; - if (Test-Path -LiteralPath (`$sigPathStr)) { break; } - } - Start-Sleep -Milliseconds 250; - } +function Get-RetryDecision { + param($Exception, [pscustomobject]$meta) + $retryCount = if ($meta -and $meta.RetryCount -ne $null) { [int]$meta.RetryCount } else { 0 } + + $statusCode = 0 + if ($Exception -is [Microsoft.Graph.PowerShell.Authentication.ApiException]) { + $statusCode = [int]$Exception.Response.StatusCode } -} finally { try { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null; } catch {} } -"@; -$script:QueuePowerShell = [System.Management.Automation.PowerShell]::Create(); -[void]$script:QueuePowerShell.AddScript($queueWorkerScript); -[void]$script:QueuePowerShell.AddArgument($script:WorkerConfig); -$script:QueueAsyncResult =$script:QueuePowerShell.BeginInvoke(); + if ($statusCode -eq 429) { + return [pscustomobject]@{ Action = "Retry"; DelayMinutes = 5; StatusCode = 429 } + } + if ($statusCode -ge 500 -and $statusCode -lt 600) { + $delay = Get-ExponentialBackoffMinutes -RetryCount $retryCount + return [pscustomobject]@{ Action = "Retry"; DelayMinutes = $delay; StatusCode = $statusCode } + } + if ($statusCode -eq 400 -or $statusCode -eq 403 -or $statusCode -eq 404) { + return [pscustomobject]@{ Action = "Failed"; StatusCode = $statusCode } + } + + $delay = Get-ExponentialBackoffMinutes -RetryCount $retryCount + return [pscustomobject]@{ Action = "Retry"; DelayMinutes = $delay; StatusCode = $statusCode } +} -$listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress); -$listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port);$listener.Start(); +function Get-ExponentialBackoffMinutes { + param([int]$RetryCount) + if ($script:Config.Queue.RetryMinutes -and $RetryCount -lt $script:Config.Queue.RetryMinutes.Count) { + return [int]$script:Config.Queue.RetryMinutes[$RetryCount] + } + return 60 +} -Write-Log "SMTPGraphRelay V1.9 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 laeuft separat vom SMTP-Listener."; +function Move-ToFailedOrRetry { + param([string]$FilePath, [pscustomobject]$meta, [pscustomobject]$Decision, [string]$LastError) + + $dirs = Get-QueueDirectories + $queueId = if ($meta) { $meta.QueueId } else { [IO.Path]::GetFileNameWithoutExtension($FilePath) } + $metaPath = "$FilePath.json" + + $maxRetries = if ($script:Config.Queue.MaxRetries) { [int]$script:Config.Queue.MaxRetries } else { 8 } + $currentRetry = if ($meta -and $meta.RetryCount -ne $null) { [int]$meta.RetryCount } else { 0 } -$limits = Get-SmtpLimits; -Write-Log ("SMTP-Limits: max. {0} Empfaenger/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); + if ($Decision.Action -eq "Failed" -or $currentRetry -ge $maxRetries) { + $failedDir = Resolve-PathFromConfig $script:Config.Paths.Failed + if (-not (Test-Path -LiteralPath $failedDir)) { New-Item -ItemType Directory -Path $failedDir -Force | Out-Null } + + $destEml = Join-Path $failedDir (Split-Path $FilePath -Leaf) + $destMeta = "$destEml.json" + + if (Test-Path -LiteralPath $FilePath) { Move-Item -LiteralPath $FilePath -Destination $destEml -Force } + if (Test-Path -LiteralPath $metaPath) { + $meta.LastStatusCode = $Decision.StatusCode + $meta.LastError = $LastError + $meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $destMeta -Encoding UTF8 + Remove-Item -LiteralPath $metaPath -Force -ErrorAction SilentlyContinue + } + Write-Log "[$queueId] Mail endgueltig fehlgeschlagen (nach $currentRetry Versuchen) -> Ordner 'failed'." "ERROR" + } else { + $nextRetry = $currentRetry + 1 + $nextTime = (Get-Date).AddMinutes($Decision.DelayMinutes).ToString("o") + + if ($meta) { + $meta.RetryCount = $nextRetry + $meta.NextAttemptUtc = $nextTime + $meta.LastStatusCode = $Decision.StatusCode + $meta.LastError = $LastError + $meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8 + } + Write-Log "[$queueId] Mail wird in $($Decision.DelayMinutes) Min. erneut versucht (Versuch $nextRetry)." "WARN" + } +} -$authSettings = Get-SmtpAuthSettings; -$authMode = if ($authSettings.RequireAuth) { "erforderlich"; } else { "optional/deaktiviert"; }; -$userCount = @($authSettings.Users).Count; -Write-Log ("SMTP-AUTH: {0}; {1} Benutzer; max. {2} Fehlversuche/Verbindung." -f $authMode, $userCount,$authSettings.AuthMaxFailures); -Write-Log ("Graceful Shutdown: bis zu {0} Sekunden fuer aktive Sessions/Worker." -f $script:GracefulShutdownSeconds); +function Start-SmtpListener { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + $ip = [System.IPAddress]::Parse($script:Config.Smtp.ListenAddress) + $port = [int]$script:Config.Smtp.Port + + $listener = New-Object System.Net.Sockets.TcpListener($ip, $port) + $listener.Start() + Write-Log "SMTP Listener gestartet auf $($script:Config.Smtp.ListenAddress):$port" -$debugSettings = Get-DebugSettings; -if ($debugSettings.Enabled) { Write-Log "ACHTUNG: QA-/Debug-Testmodus ist AKTIV." "WARN"; } + $dirs = Get-QueueDirectories -$logSettings = Get-LogSettings; -Write-Log ("Log-Rotation: max. {0} MB pro Datei, Aufbewahrung {1} Tage." -f $logSettings.MaxFileSizeMB, $logSettings.RetentionDays); - -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); - } - - if ($script:QueueAsyncResult.IsCompleted) { - if (Test-Path -LiteralPath ($script:ShutdownSignalPath)) { break; } - try { [void]$script:QueuePowerShell.EndInvoke($script:QueueAsyncResult); throw "Queue-Worker wurde unerwartet beendet."; } catch { throw "Queue-Worker wurde unerwartet beendet: $($_.Exception.Message)"; } + if (Test-Path -LiteralPath (Join-Path $PSScriptRoot "shutdown.request")) { + Write-Log "Shutdown-Signal erhalten. Beende Listener..." + break } if ($listener.Pending()) { - $client =$listener.AcceptTcpClient(); - Start-SmtpClientWorker -Client ($client); - } else { - Start-Sleep -Milliseconds 100; + $client = $listener.AcceptTcpClient() + $remoteIp = ($client.Client.RemoteEndPoint -as [System.Net.IPEndPoint]).Address.IPAddressToString + + if (-not (Test-ClientAllowed $remoteIp)) { + Write-Log "Verbindung von nicht erlaubteter IP $remoteIp abgelehnt." "WARN" + $client.Close() + continue + } + + [void][Threading.ThreadPool]::QueueUserWorkItem({ + param($TcpClient, $ClientIp) + try { + $stream = $TcpClient.GetStream() + $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::ASCII) + $writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII) + $writer.AutoFlush = $true + + $writer.WriteLine("220 $($script:Config.Smtp.Hostname) ESMTP SMTPGraphRelay Ready") + + $mailFrom = "" + $recipients = New-Object System.Collections.Generic.List[string] + $dataLines = New-Object System.Collections.Generic.List[string] + $receivingData = $false + $authenticatedUser = "" + $authStep = 0 + + while ($TcpClient.Connected -and ($line = $reader.ReadLine()) -ne $null) { + $trimmed = $line.TrimEnd("`r", "`n") + + if ($receivingData) { + if ($trimmed -eq ".") { + $receivingData = $false + if (Save-SmtpMessage -Lines $dataLines -RemoteAddress $ClientIp -AuthenticatedUser $authenticatedUser -MailFrom $mailFrom -Recipients $recipients) { + $writer.WriteLine("250 2.0.0 OK: Message accepted") + } else { + $writer.WriteLine("451 4.3.0 Internal server error") + } + $mailFrom = "" + $recipients.Clear() + $dataLines.Clear() + } else { + if ($trimmed.StartsWith("..")) { $trimmed = $trimmed.Substring(1) } + $dataLines.Add($trimmed) + } + continue + } + + if ($trimmed -match '^(?i)EHLO\s+(.*)$' -or $trimmed -match '^(?i)HELO\s+(.*)$') { + $writer.WriteLine("250-$($script:Config.Smtp.Hostname) Hello") + $writer.WriteLine("250-SIZE 26214400") + $writer.WriteLine("250-8BITMIME") + if ($script:Config.Smtp.RequireAuth) { + $writer.WriteLine("250-AUTH LOGIN PLAIN") + } + $writer.WriteLine("250 HELP") + } + elseif ($trimmed -match '^(?i)MAIL FROM:\s*(?:<([^>]*)>|([^\s>]+))(?:\s+.*)?$') { + $mailFrom = if ($Matches[1]) { $Matches[1] } else { $Matches[2] } + $writer.WriteLine("250 2.1.0 Sender OK") + } + elseif ($trimmed -match '^(?i)RCPT TO:\s*(?:<([^>]+)>|([^\s>]+))(?:\s+.*)?$') { + $rcpt = if ($Matches[1]) { $Matches[1] } else { $Matches[2] } + [void]$recipients.Add($rcpt) + $writer.WriteLine("250 2.1.5 Recipient OK") + } + elseif ($trimmed -match '^(?i)DATA$') { + if ($recipients.Count -eq 0) { + $writer.WriteLine("503 5.5.1 Error: need RCPT command") + continue + } + $receivingData = $true + $dataLines.Clear() + $writer.WriteLine("354 Start mail input; end with .") + } + elseif ($trimmed -match '^(?i)QUIT$') { + $writer.WriteLine("221 2.0.0 Bye") + break + } + else { + $writer.WriteLine("500 5.5.2 Command unrecognized") + } + } + } catch { + Write-Log "Fehler in Client-Session ($ClientIp): $($_.Exception.Message)" "ERROR" + } finally { + $TcpClient.Close() + } + }, $client) } + + # Queue verarbeiten + $pendingFiles = Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File -ErrorAction SilentlyContinue + foreach ($file in $pendingFiles) { + $metaPath = "$($file.FullName).json" + $meta = $null + if (Test-Path -LiteralPath $metaPath) { + try { + $meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json + if ($meta.PSObject.Properties.Name -contains "NextAttemptUtc" -and $meta.NextAttemptUtc) { + $nextTry = [DateTime]::Parse($meta.NextAttemptUtc) + if ((Get-Date) -lt $nextTry) { continue } + } + } catch {} + } + Send-QueuedMail -FilePath $file.FullName -meta $meta + } + + Start-Sleep -Milliseconds 500 } + + $listener.Stop() } -finally { - Write-Log "SMTPGraphRelay faehrt kontrolliert herunter..."; - try { $listener.Stop(); } catch {} - $deadline = (Get-Date).AddSeconds($script:GracefulShutdownSeconds); +# Hauptschleife +try { + if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Konfigurationsdatei nicht gefunden: $ConfigPath" } + $script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json - while ($script:ActiveSmtpWorkers.Count -gt 0 -and (Get-Date) -lt$deadline) { - Remove-CompletedSmtpWorkers; - if ($script:ActiveSmtpWorkers.Count -gt 0) { Start-Sleep -Milliseconds 200; } - } + Write-Log "SMTPGraphRelay wird gestartet..." + Test-RelayCertificateExpiry -ForceLog - 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(); - } - - try { $script:SmtpRunspacePool.Close(); } catch {} - try { $script:SmtpRunspacePool.Dispose(); } catch {} - - 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 { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null; } catch {} - - Remove-Item -LiteralPath ($script:ShutdownSignalPath) -Force -ErrorAction SilentlyContinue; - Write-Log "SMTPGraphRelay sauber beendet."; + Start-SmtpListener +} catch { + Write-Log "Kritischer Fehler im Relay: $($_.Exception.Message)" "ERROR" + exit 1 } \ No newline at end of file