#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: 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 Send-QueuedMail { param([Parameter(Mandatory)][string]$FilePath) $metaPath = "$FilePath.json" $meta = $null if (Test-Path -LiteralPath $metaPath) { $meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json } $bytes = [IO.File]::ReadAllBytes($FilePath) if ($script:Config.Graph.ForceSender) { $bytes = Set-MimeSender -MimeBytes $bytes -Sender $script:Config.Graph.SenderMailbox } Get-GraphConnection $sender = [Uri]::EscapeDataString($script:Config.Graph.SenderMailbox) $uri = "https://graph.microsoft.com/v1.0/users/$sender/sendMail" $base64 = [Convert]::ToBase64String($bytes) try { Invoke-MgGraphRequest ` -Method POST ` -Uri $uri ` -Body $base64 ` -ContentType "text/plain" ` -OutputType PSObject | Out-Null Write-Log "Mail gesendet: $(Split-Path $FilePath -Leaf)" Remove-Item -LiteralPath $FilePath -Force if (Test-Path -LiteralPath $metaPath) { Remove-Item -LiteralPath $metaPath -Force } return $true } catch { $retryCount = 0 if ($meta -and $null -ne $meta.RetryCount) { $retryCount = [int]$meta.RetryCount } $retryCount++ $maxRetries = [int]$script:Config.Queue.MaxRetries Write-Log "Graph-Versand fehlgeschlagen (Versuch $retryCount/$maxRetries): $($_.Exception.Message)" "WARN" if ($retryCount -ge $maxRetries) { $failedDir = Resolve-PathFromConfig $script:Config.Paths.Failed New-Item -ItemType Directory -Path $failedDir -Force | Out-Null Move-Item -LiteralPath $FilePath -Destination (Join-Path $failedDir (Split-Path $FilePath -Leaf)) -Force if (Test-Path -LiteralPath $metaPath) { Move-Item -LiteralPath $metaPath -Destination (Join-Path $failedDir (Split-Path $metaPath -Leaf)) -Force } Write-Log "Mail nach $retryCount Fehlversuchen nach FAILED verschoben." "ERROR" } else { $delays = @($script:Config.Queue.RetryMinutes) $delay = if ($retryCount -le $delays.Count) { [int]$delays[$retryCount - 1] } else { [int]$delays[-1] } $next = (Get-Date).AddMinutes($delay) $newMeta = [ordered]@{ RetryCount = $retryCount NextAttemptUtc = $next.ToUniversalTime().ToString("o") LastError = $_.Exception.Message } $newMeta | ConvertTo-Json | Set-Content -LiteralPath $metaPath -Encoding UTF8 } return $false } } function Process-Queue { $queueDir = Resolve-PathFromConfig $script:Config.Paths.Queue New-Item -ItemType Directory -Path $queueDir -Force | Out-Null foreach ($file in Get-ChildItem -LiteralPath $queueDir -Filter "*.eml" -File | Sort-Object CreationTimeUtc) { $metaPath = "$($file.FullName).json" if (Test-Path -LiteralPath $metaPath) { try { $meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json if ($meta.NextAttemptUtc) { $next = [DateTime]::Parse($meta.NextAttemptUtc).ToUniversalTime() if ($next -gt [DateTime]::UtcNow) { continue } } } catch {} } [void](Send-QueuedMail -FilePath $file.FullName) } } function Save-SmtpMessage { param( [Parameter(Mandatory)] [AllowEmptyCollection()] [AllowEmptyString()] [System.Collections.Generic.List[string]]$Lines, [Parameter(Mandatory)] [string]$MailFrom, [Parameter(Mandatory)] [string[]]$Recipients, [Parameter(Mandatory)] [string]$RemoteAddress ) $queueDir = Resolve-PathFromConfig $script:Config.Paths.Queue New-Item -ItemType Directory -Path $queueDir -Force | Out-Null $id = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmssfff"), ([guid]::NewGuid().ToString("N").Substring(0,8)) $path = Join-Path $queueDir "$id.eml" # SMTP DATA wird in CRLF normalisiert. $raw = ($Lines -join "`r`n") + "`r`n" $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [IO.File]::WriteAllText($path, $raw, $utf8NoBom) $meta = [ordered]@{ ReceivedUtc = [DateTime]::UtcNow.ToString("o") RemoteAddress = $RemoteAddress EnvelopeFrom = $MailFrom EnvelopeRecipients = @($Recipients) RetryCount = 0 NextAttemptUtc = [DateTime]::UtcNow.ToString("o") } $meta | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath "$path.json" -Encoding UTF8 Write-Log "Mail angenommen: $id | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress" return $path } 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 "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 $reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8, $true, 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] Write-SmtpLine $writer ("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|HELO)\s+(.+)$') { Write-SmtpLine $writer ("250-{0}" -f $script:Config.Smtp.Hostname) Write-SmtpLine $writer ("250-SIZE {0}" -f ([int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024)) Write-SmtpLine $writer "250 8BITMIME" } elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') { $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 foreach ($p in @($script:Config.Paths.Queue, $script:Config.Paths.Failed, $script:Config.Paths.Logs)) { New-Item -ItemType Directory -Path (Resolve-PathFromConfig $p) -Force | Out-Null } $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." }