Files
SMTPGraphRelay/SMTPGraphRelay.ps1
T

469 lines
16 KiB
PowerShell

#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 <CR><LF>.<CR><LF>"
$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."
}