#Requires -Version 5.1 <# .SYNOPSIS SMTPGraphRelay - einfacher SMTP Store-and-Forward Relay zu Microsoft Graph. .DESCRIPTION Nimmt lokale SMTP-Mails an, speichert sie als .eml in einer Queue und sendet sie anschließend per Microsoft Graph sendMail mit App-only Zertifikatsauthentifizierung. V1.2: robuste Queue, statuscodeabhängiger Graph-Retry, EHLO/HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT #> [CmdletBinding()] param( [string]$ConfigPath = "$PSScriptRoot\config.json" ) $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 try { if ($script:Config -and $script:Config.Paths.Logs) { $logDir = $script:Config.Paths.Logs if (-not [IO.Path]::IsPathRooted($logDir)) { $logDir = Join-Path $PSScriptRoot $logDir } New-Item -ItemType Directory -Path $logDir -Force | Out-Null Add-Content -LiteralPath (Join-Path $logDir "SMTPGraphRelay.log") -Value $line -Encoding UTF8 } } 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) foreach ($entry in @($script:Config.Smtp.AllowedNetworks)) { 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 ) # Headerbereich als Latin1 lesen, damit Bytes 1:1 erhalten bleiben. $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) } function Get-QueueDirectories { $queueRoot = Resolve-PathFromConfig $script:Config.Paths.Queue $failedDir = Resolve-PathFromConfig $script:Config.Paths.Failed return [pscustomobject]@{ Root = $queueRoot Incoming = Join-Path $queueRoot "incoming" Pending = Join-Path $queueRoot "pending" Processing = Join-Path $queueRoot "processing" Failed = $failedDir } } function Initialize-QueueDirectories { $dirs = Get-QueueDirectories foreach ($dir in @( $dirs.Root, $dirs.Incoming, $dirs.Pending, $dirs.Processing, $dirs.Failed )) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } # Alte V1-Mails direkt aus queue\ nach pending migrieren. foreach ($file in Get-ChildItem -LiteralPath $dirs.Root -Filter "*.eml" -File -ErrorAction SilentlyContinue) { $target = Join-Path $dirs.Pending $file.Name if (-not (Test-Path -LiteralPath $target)) { Move-Item -LiteralPath $file.FullName -Destination $target -Force } $oldMeta = "$($file.FullName).json" if (Test-Path -LiteralPath $oldMeta) { $targetMeta = "$target.json" if (-not (Test-Path -LiteralPath $targetMeta)) { Move-Item -LiteralPath $oldMeta -Destination $targetMeta -Force } } Write-Log "Alte Queue-Mail nach pending migriert: $($file.Name)" } # Nach Absturz/Neustart können Dateien in processing liegen. # Sie werden wieder nach pending gestellt und später erneut versucht. foreach ($file in Get-ChildItem -LiteralPath $dirs.Processing -Filter "*.eml" -File -ErrorAction SilentlyContinue) { $pendingPath = Join-Path $dirs.Pending $file.Name $processingMeta = "$($file.FullName).json" $pendingMeta = "$pendingPath.json" if (Test-Path -LiteralPath $processingMeta) { Move-Item -LiteralPath $processingMeta -Destination $pendingMeta -Force } Move-Item -LiteralPath $file.FullName -Destination $pendingPath -Force Write-Log "Processing-Mail nach Neustart zurück nach pending gestellt: $($file.Name)" "WARN" } } function Get-GraphFailureInfo { param( [Parameter(Mandatory)] $ErrorRecord ) $statusCode = $null $retryAfterSeconds = $null $message = $ErrorRecord.Exception.Message try { $response = $ErrorRecord.Exception.Response if ($response) { try { if ($null -ne $response.StatusCode) { $statusCode = [int]$response.StatusCode } } catch {} try { $headers = $response.Headers if ($headers) { # HttpResponseMessage / HttpResponseHeaders try { $values = $null if ($headers.TryGetValues("Retry-After", [ref]$values)) { $raw = @($values)[0] if ($raw -match '^\d+$') { $retryAfterSeconds = [int]$raw } else { $retryDate = [DateTimeOffset]::Parse($raw) $seconds = [Math]::Ceiling(($retryDate - [DateTimeOffset]::UtcNow).TotalSeconds) if ($seconds -gt 0) { $retryAfterSeconds = [int]$seconds } } } } catch {} # WebResponse-artige Header if ($null -eq $retryAfterSeconds) { try { $raw = $headers["Retry-After"] if ($raw) { if ($raw -match '^\d+$') { $retryAfterSeconds = [int]$raw } else { $retryDate = [DateTimeOffset]::Parse($raw) $seconds = [Math]::Ceiling(($retryDate - [DateTimeOffset]::UtcNow).TotalSeconds) if ($seconds -gt 0) { $retryAfterSeconds = [int]$seconds } } } } catch {} } } } catch {} } } catch {} # Fallback: Statuscode aus Text extrahieren, falls das Graph-Modul ihn nur dort liefert. if ($null -eq $statusCode) { $combined = "$message $($ErrorRecord | Out-String)" if ($combined -match '(?]*)>') { $mailFrom = $matches[1] $recipients.Clear() Write-SmtpLine $writer "250 2.1.0 OK" } elseif ($line -match '^(?i)RCPT TO:\s*<([^>]+)>') { if (-not $mailFrom) { Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM first" continue } $recipients.Add($matches[1]) Write-SmtpLine $writer "250 2.1.5 OK" } elseif ($line -match '^(?i)DATA\s*$') { if (-not $mailFrom -or $recipients.Count -eq 0) { Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM and RCPT TO first" continue } Write-SmtpLine $writer "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 } # SMTP dot-stuffing rückgängig machen if ($dataLine.StartsWith("..")) { $dataLine = $dataLine.Substring(1) } $size += [System.Text.Encoding]::UTF8.GetByteCount($dataLine) + 2 if ($size -gt $maxBytes) { $tooLarge = $true } elseif (-not $tooLarge) { $data.Add($dataLine) } } if ($tooLarge) { Write-SmtpLine $writer "552 5.3.4 Message size exceeds fixed maximum message size" Write-Log "Mail von $remoteIp wegen Größenlimit verworfen." "WARN" } else { [void](Save-SmtpMessage -Lines $data -MailFrom $mailFrom -Recipients $recipients.ToArray() -RemoteAddress $remoteIp.ToString()) Write-SmtpLine $writer "250 2.0.0 Queued" } $mailFrom = $null $recipients.Clear() } elseif ($line -match '^(?i)RSET\s*$') { $mailFrom = $null $recipients.Clear() Write-SmtpLine $writer "250 2.0.0 Reset" } elseif ($line -match '^(?i)NOOP(?:\s+.*)?$') { Write-SmtpLine $writer "250 2.0.0 OK" } elseif ($line -match '^(?i)QUIT\s*$') { Write-SmtpLine $writer "221 2.0.0 Bye" break } elseif ($line -match '^(?i)(AUTH|STARTTLS)\b') { Write-SmtpLine $writer "502 5.5.1 Command not implemented" } else { Write-SmtpLine $writer "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 {} } } if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Konfiguration nicht gefunden: $ConfigPath. Bitte zuerst Setup-SMTPGraphRelay.ps1 ausführen." } $script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json New-Item -ItemType Directory -Path (Resolve-PathFromConfig $script:Config.Paths.Logs) -Force | Out-Null Initialize-QueueDirectories $listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress) $listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port) $listener.Start() Write-Log "SMTPGraphRelay gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)" Write-Log "Graph-Absender: $($script:Config.Graph.SenderMailbox)" $lastQueueRun = [DateTime]::MinValue try { while ($true) { if ((Get-Date) -gt $lastQueueRun.AddSeconds([int]$script:Config.Queue.PollSeconds)) { try { Process-Queue } catch { Write-Log "Queue-Worker: $($_.Exception.Message)" "ERROR" } $lastQueueRun = Get-Date } if ($listener.Pending()) { $client = $listener.AcceptTcpClient() # V1 verarbeitet Clients seriell. Für typische Geräte-/Monitoring-Relays bewusst simpel. Handle-SmtpClient -Client $client } else { Start-Sleep -Milliseconds 200 } } } finally { $listener.Stop() Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null Write-Log "SMTPGraphRelay beendet." }