[CmdletBinding()] param( [string]$ConfigPath = "$PSScriptRoot\config.json" ) #Requires -Version 5.1 #Requires -RunAsAdministrator $ErrorActionPreference = "Stop" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 function Write-Log { 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 -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([string]$Path) if ([IO.Path]::IsPathRooted($Path)) { return $Path } return (Join-Path $PSScriptRoot $Path) } function Get-QueueDirectories { $qPath = Resolve-PathFromConfig $script:Config.Paths.Queue return [pscustomobject]@{ Incoming = (Join-Path $qPath "incoming") Pending = (Join-Path $qPath "pending") Processing = (Join-Path $qPath "processing") } } function New-QueueId { return [guid]::NewGuid().ToString("N") } function Test-FixedTimeEquals { param([byte[]]$A, [byte[]]$B) if ($A -eq $null -or $B -eq $null -or $A.Length -ne $B.Length) { return $false } $diff = 0 for ($i = 0; $i -lt $A.Length; $i++) { $diff = $diff -bxor ($A[$i] -bxor $B[$i]) } return ($diff -eq 0) } function Verify-SmtpCredentials { param([string]$Username, [string]$PlainPassword) if (-not $script:Config.Smtp.AuthUsers) { return $false } foreach ($u in $script:Config.Smtp.AuthUsers) { if ([string]$u.Username -eq $Username) { try { $salt = [Convert]::FromBase64String($u.Salt) $expectedHash = [Convert]::FromBase64String($u.PasswordHash) $iterations = [int]$u.Iterations $derive = New-Object System.Security.Cryptography.Rfc2898DeriveBytes($PlainPassword, $salt, $iterations, [System.Security.Cryptography.HashAlgorithmName]::SHA256) $actualHash = $derive.GetBytes($expectedHash.Length) $derive.Dispose() return Test-FixedTimeEquals $actualHash $expectedHash } catch { return $false } } } return $false } function Test-ClientAllowed { param([string]$IpAddress) $ip = [System.IPAddress]::Parse($IpAddress) if ($ip.IPAddressToString -eq "127.0.0.1" -or $ip.IPAddressToString -eq "::1") { return $true } if ($script:Config.Smtp.AllowedNetworks) { foreach ($net in $script:Config.Smtp.AllowedNetworks) { if (Test-IpInCidr $ip $net) { return $true } } } return $false } function Test-IpInCidr { param([System.IPAddress]$Ip, [string]$Cidr) try { $parts = $Cidr.Split('/') $netAddr = [System.IPAddress]::Parse($parts[0]) $prefixLength = [int]$parts[1] $ipBytes = $Ip.GetAddressBytes() $netBytes = $netAddr.GetAddressBytes() if ($ipBytes.Length -ne $netBytes.Length) { return $false } $bytesToCheck = [Math]::Floor($prefixLength / 8) $bitsToCheck = $prefixLength % 8 for ($i = 0; $i -lt $bytesToCheck; $i++) { if ($ipBytes[$i] -ne $netBytes[$i]) { return $false } } if ($bitsToCheck -gt 0) { $mask = [byte](256 - [Math]::Pow(2, (8 - $bitsToCheck))) if (($ipBytes[$bytesToCheck] -band $mask) -ne ($netBytes[$bytesToCheck] -band $mask)) { return $false } } return $true } catch { return $false } } function Test-UnauthenticatedNetworkAllowed { param([string]$IpAddress) if (-not $script:Config.Smtp.AllowUnauthenticatedNetworks) { return $false } $ip = [System.IPAddress]::Parse($IpAddress) foreach ($net in $script:Config.Smtp.AllowUnauthenticatedNetworks) { if (Test-IpInCidr $ip $net) { return $true } } return $false } function Add-RelayMessageHeaders { param( [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][System.Collections.Generic.List[string]]$Lines, [Parameter(Mandatory)][string]$QueueId, [Parameter(Mandatory)][string]$RemoteAddress, [string]$MailFrom = "", [string[]]$Recipients = @() ) $result = New-Object System.Collections.Generic.List[string] $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 $hasDate = $false $hasFrom = $false $hasTo = $false for ($i = 0; $i -lt $headerEnd; $i++) { if ($Lines[$i] -match '^(?i)Message-ID\s*:') { $hasMessageId = $true } if ($Lines[$i] -match '^(?i)Date\s*:') { $hasDate = $true } if ($Lines[$i] -match '^(?i)From\s*:') { $hasFrom = $true } if ($Lines[$i] -match '^(?i)(To|Cc|Bcc)\s*:') { $hasTo = $true } } $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" $result.Add($received) if (-not $hasDate) { $result.Add("Date: $date") } if (-not $hasMessageId) { $safeHost = ($hostname -replace '[^A-Za-z0-9._-]', '-') $utcNowStr = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss') $messageId = "<$QueueId.$utcNowStr@$safeHost>" $result.Add("Message-ID: $messageId") } if (-not $hasFrom -and -not [string]::IsNullOrWhiteSpace($MailFrom)) { $result.Add("From: <$MailFrom>") } if (-not $hasTo -and @($Recipients).Count -gt 0) { $toFormatted = ($Recipients | ForEach-Object { "<$_>" }) -join ", " $result.Add("To: $toFormatted") } for ($i = 0; $i -lt $Lines.Count; $i++) { $result.Add($Lines[$i]) } if ($separatorIndex -lt 0) { $result.Add("") } return $result } function Save-SmtpMessage { param( [Parameter(Mandatory)][System.Collections.Generic.List[string]]$Lines, [Parameter(Mandatory)][string]$RemoteAddress, [Parameter(Mandatory)][string]$AuthenticatedUser, [Parameter(Mandatory)][string]$MailFrom, [Parameter(Mandatory)][string[]]$Recipients ) $dirs = Get-QueueDirectories $queueId = New-QueueId $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 { $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 { Write-Log "[$queueId] Fehler beim Speichern der Nachricht: $($_.Exception.Message)" "ERROR" return $false } } function Get-RelayCertificateStatus { $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]@{ Thumbprint = $thumb NotAfter = $cert.NotAfter DaysRemaining = $daysRemaining Expired = $expired } } function Send-CertificateAlertMail { 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 } $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("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") } $pendingEml = Join-Path $dirs.Pending "$queueId.eml" $pendingMeta = "$pendingEml.json" try { $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) try { $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 ($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" } elseif ($ForceLog) { 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) { $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 } } 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)." } } 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 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 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 } } } $bytes = Set-MimeSender -MimeBytes $bytes -Sender $effectiveSender try { 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 { $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 } } 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 } 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 } } 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 } 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 } 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" } } 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" $dirs = Get-QueueDirectories while ($true) { if (Test-Path -LiteralPath (Join-Path $PSScriptRoot "shutdown.request")) { Write-Log "Shutdown-Signal erhalten. Beende Listener..." break } if ($listener.Pending()) { $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() } # Hauptschleife try { if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Konfigurationsdatei nicht gefunden: $ConfigPath" } $script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json Write-Log "SMTPGraphRelay wird gestartet..." Test-RelayCertificateExpiry -ForceLog Start-SmtpListener } catch { Write-Log "Kritischer Fehler im Relay: $($_.Exception.Message)" "ERROR" exit 1 }