Files
SMTPGraphRelay/SMTPGraphRelay.ps1
T

1325 lines
44 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.5: Log-Rotation, Zertifikatsüberwachung, parallele SMTP-Clients, Queue-Worker und robuste Graph-Retry-Logik
#>
[CmdletBinding()]
param(
[string]$ConfigPath = "$PSScriptRoot\config.json"
)
$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)
foreach ($file in Get-ChildItem -LiteralPath $logDir -File -Filter "SMTPGraphRelay-*.log" -ErrorAction SilentlyContinue) {
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 {}
}
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)) {
if ($PSScriptRoot) {
$logDir = Join-Path $PSScriptRoot $logDir
} else {
return
}
}
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
$logFile = Join-Path $logDir "SMTPGraphRelay.log"
# Mehrere SMTP-Runspaces und der Queue-Worker können gleichzeitig loggen.
# Ein benannter Mutex schützt Rotation und Schreibzugriff gemeinsam.
$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()
}
}
}
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 '(?<!\d)(400|401|403|404|408|409|410|412|413|415|422|423|429|500|501|502|503|504)(?!\d)') {
$statusCode = [int]$matches[1]
}
}
return [pscustomobject]@{
StatusCode = $statusCode
RetryAfterSeconds = $retryAfterSeconds
Message = $message
}
}
function Get-RetryDecision {
param(
[Parameter(Mandatory)]
$FailureInfo,
[Parameter(Mandatory)]
[int]$RetryCount
)
$statusCode = $FailureInfo.StatusCode
# Graph Throttling: Retry-After bevorzugen.
if ($statusCode -eq 429) {
if ($FailureInfo.RetryAfterSeconds -and $FailureInfo.RetryAfterSeconds -gt 0) {
return [pscustomobject]@{
Retry = $true
DelaySeconds = [int]$FailureInfo.RetryAfterSeconds
Reason = "Graph 429 / Retry-After"
}
}
# Falls kein Retry-After vorhanden ist: exponentielles Backoff.
$seconds = [Math]::Min(3600, [Math]::Pow(2, [Math]::Min($RetryCount, 10)) * 5)
return [pscustomobject]@{
Retry = $true
DelaySeconds = [int]$seconds
Reason = "Graph 429 / exponentielles Backoff"
}
}
# Serverfehler und Request Timeout sind typischerweise temporär.
if ($statusCode -eq 408 -or ($statusCode -ge 500 -and $statusCode -le 599)) {
$delays = @($script:Config.Queue.RetryMinutes)
$index = [Math]::Min([Math]::Max($RetryCount - 1, 0), $delays.Count - 1)
return [pscustomobject]@{
Retry = $true
DelaySeconds = ([int]$delays[$index] * 60)
Reason = "temporärer HTTP-Fehler $statusCode"
}
}
# Typische Auth-/RBAC-/Requestfehler sind ohne Konfigurationsänderung permanent.
if ($statusCode -in @(400,401,403,404,409,410,412,413,415,422,423)) {
return [pscustomobject]@{
Retry = $false
DelaySeconds = 0
Reason = "permanenter HTTP-Fehler $statusCode"
}
}
# Kein Statuscode: meist DNS/TLS/Socket/Timeout. Mit normalem Retry behandeln.
if ($null -eq $statusCode) {
$delays = @($script:Config.Queue.RetryMinutes)
$index = [Math]::Min([Math]::Max($RetryCount - 1, 0), $delays.Count - 1)
return [pscustomobject]@{
Retry = $true
DelaySeconds = ([int]$delays[$index] * 60)
Reason = "Netzwerk-/Transportfehler"
}
}
# Unbekannte 4xx lieber nicht endlos wiederholen.
if ($statusCode -ge 400 -and $statusCode -le 499) {
return [pscustomobject]@{
Retry = $false
DelaySeconds = 0
Reason = "unbekannter Clientfehler $statusCode"
}
}
# Defensive Default-Policy.
$delays = @($script:Config.Queue.RetryMinutes)
$index = [Math]::Min([Math]::Max($RetryCount - 1, 0), $delays.Count - 1)
return [pscustomobject]@{
Retry = $true
DelaySeconds = ([int]$delays[$index] * 60)
Reason = "unbekannter temporärer Fehler"
}
}
function Move-QueueItem {
param(
[Parameter(Mandatory)][string]$SourceEml,
[Parameter(Mandatory)][string]$DestinationDirectory
)
New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null
$sourceMeta = "$SourceEml.json"
$destinationEml = Join-Path $DestinationDirectory (Split-Path $SourceEml -Leaf)
$destinationMeta = "$destinationEml.json"
# Metadaten zuerst bewegen, .eml zuletzt. Dadurch sieht der Worker niemals
# eine neue .eml-Datei ohne bereits vorhandene Metadaten.
if (Test-Path -LiteralPath $sourceMeta) {
Move-Item -LiteralPath $sourceMeta -Destination $destinationMeta -Force
}
Move-Item -LiteralPath $SourceEml -Destination $destinationEml -Force
return $destinationEml
}
function Send-QueuedMail {
param([Parameter(Mandatory)][string]$FilePath)
$dirs = Get-QueueDirectories
$metaPath = "$FilePath.json"
$meta = $null
if (Test-Path -LiteralPath $metaPath) {
try {
$meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
} catch {
Write-Log "Queue-Metadaten unlesbar für $(Split-Path $FilePath -Leaf): $($_.Exception.Message)" "WARN"
}
}
if (-not $meta) {
$meta = [pscustomobject]@{
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
RetryCount = 0
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
}
}
$bytes = [IO.File]::ReadAllBytes($FilePath)
if ($script:Config.Graph.ForceSender) {
$bytes = Set-MimeSender -MimeBytes $bytes -Sender $script:Config.Graph.SenderMailbox
}
try {
Get-GraphConnection
$sender = [Uri]::EscapeDataString($script:Config.Graph.SenderMailbox)
$uri = "https://graph.microsoft.com/v1.0/users/$sender/sendMail"
$base64 = [Convert]::ToBase64String($bytes)
Invoke-MgGraphRequest `
-Method POST `
-Uri $uri `
-Body $base64 `
-ContentType "text/plain" `
-OutputType PSObject | Out-Null
# Graph sendMail liefert bei Annahme 202 Accepted.
# Invoke-MgGraphRequest wirft bei Fehlern eine Exception; kein Fehler bedeutet hier angenommen.
Write-Log "Mail von Graph angenommen: $(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 ($null -ne $meta.RetryCount) {
$retryCount = [int]$meta.RetryCount
}
$retryCount++
$failure = Get-GraphFailureInfo -ErrorRecord $_
$decision = Get-RetryDecision -FailureInfo $failure -RetryCount $retryCount
$maxRetries = [int]$script:Config.Queue.MaxRetries
$statusText = if ($null -ne $failure.StatusCode) {
"HTTP $($failure.StatusCode)"
} else {
"ohne HTTP-Status"
}
if (-not $decision.Retry) {
Write-Log ("Graph-Versand permanent fehlgeschlagen ({0}, {1}): {2}" -f $statusText, $decision.Reason, $failure.Message) "ERROR"
$newMeta = [ordered]@{
ReceivedUtc = $meta.ReceivedUtc
RemoteAddress = $meta.RemoteAddress
EnvelopeFrom = $meta.EnvelopeFrom
EnvelopeRecipients = @($meta.EnvelopeRecipients)
RetryCount = $retryCount
FailedUtc = [DateTime]::UtcNow.ToString("o")
LastStatusCode = $failure.StatusCode
LastError = $failure.Message
FailureReason = $decision.Reason
}
$newMeta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8
[void](Move-QueueItem -SourceEml $FilePath -DestinationDirectory $dirs.Failed)
return $false
}
if ($retryCount -ge $maxRetries) {
Write-Log ("Graph-Versand nach {0} Versuchen endgültig fehlgeschlagen ({1}): {2}" -f $retryCount, $statusText, $failure.Message) "ERROR"
$newMeta = [ordered]@{
ReceivedUtc = $meta.ReceivedUtc
RemoteAddress = $meta.RemoteAddress
EnvelopeFrom = $meta.EnvelopeFrom
EnvelopeRecipients = @($meta.EnvelopeRecipients)
RetryCount = $retryCount
FailedUtc = [DateTime]::UtcNow.ToString("o")
LastStatusCode = $failure.StatusCode
LastError = $failure.Message
FailureReason = "MaxRetries erreicht"
}
$newMeta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8
[void](Move-QueueItem -SourceEml $FilePath -DestinationDirectory $dirs.Failed)
return $false
}
$next = (Get-Date).AddSeconds([int]$decision.DelaySeconds)
$newMeta = [ordered]@{
ReceivedUtc = $meta.ReceivedUtc
RemoteAddress = $meta.RemoteAddress
EnvelopeFrom = $meta.EnvelopeFrom
EnvelopeRecipients = @($meta.EnvelopeRecipients)
RetryCount = $retryCount
NextAttemptUtc = $next.ToUniversalTime().ToString("o")
LastAttemptUtc = [DateTime]::UtcNow.ToString("o")
LastStatusCode = $failure.StatusCode
LastError = $failure.Message
RetryReason = $decision.Reason
}
$newMeta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8
$delayText = if ($decision.DelaySeconds -ge 60) {
"{0:N1} Min." -f ($decision.DelaySeconds / 60)
} else {
"$($decision.DelaySeconds) Sek."
}
Write-Log ("Graph-Versand fehlgeschlagen (Versuch {0}/{1}, {2}, {3}). Neuer Versuch in {4}: {5}" -f `
$retryCount, $maxRetries, $statusText, $decision.Reason, $delayText, $failure.Message) "WARN"
[void](Move-QueueItem -SourceEml $FilePath -DestinationDirectory $dirs.Pending)
return $false
}
}
function Process-Queue {
$dirs = Get-QueueDirectories
foreach ($file in Get-ChildItem -LiteralPath $dirs.Pending -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 {
Write-Log "Queue-Metadaten konnten nicht gelesen werden: $metaPath" "WARN"
}
}
try {
$processingPath = Move-QueueItem `
-SourceEml $file.FullName `
-DestinationDirectory $dirs.Processing
[void](Send-QueuedMail -FilePath $processingPath)
}
catch {
Write-Log "Queue-Verarbeitung für $($file.Name) fehlgeschlagen: $($_.Exception.Message)" "ERROR"
# Falls das Verschieben nach processing bereits geklappt hat, zurück nach pending.
$possibleProcessing = Join-Path $dirs.Processing $file.Name
if (Test-Path -LiteralPath $possibleProcessing) {
try {
[void](Move-QueueItem -SourceEml $possibleProcessing -DestinationDirectory $dirs.Pending)
} catch {
Write-Log "Queue-Recovery fehlgeschlagen für $($file.Name): $($_.Exception.Message)" "ERROR"
}
}
}
}
}
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
)
$dirs = Get-QueueDirectories
$id = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmssfff"), ([guid]::NewGuid().ToString("N").Substring(0,8))
$incomingEmlTmp = Join-Path $dirs.Incoming "$id.eml.tmp"
$incomingMetaTmp = Join-Path $dirs.Incoming "$id.eml.json.tmp"
$pendingEml = Join-Path $dirs.Pending "$id.eml"
$pendingMeta = "$pendingEml.json"
# SMTP DATA wird in CRLF normalisiert.
$raw = ($Lines -join "`r`n") + "`r`n"
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$meta = [ordered]@{
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
RemoteAddress = $RemoteAddress
EnvelopeFrom = $MailFrom
EnvelopeRecipients = @($Recipients)
RetryCount = 0
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
}
try {
# Erst vollständig in incoming schreiben.
[IO.File]::WriteAllText($incomingEmlTmp, $raw, $utf8NoBom)
$meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $incomingMetaTmp -Encoding UTF8
# Metadaten zuerst finalisieren, Maildatei zuletzt.
# Erst wenn die .eml in pending liegt, ist sie für den Worker sichtbar.
Move-Item -LiteralPath $incomingMetaTmp -Destination $pendingMeta -Force
Move-Item -LiteralPath $incomingEmlTmp -Destination $pendingEml -Force
}
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
}
Write-Log "Mail angenommen: $id | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress"
return $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 "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 {}
}
}
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 Schlüssel."
}
$remaining = $cert.NotAfter.ToUniversalTime() - [DateTime]::UtcNow
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)
}
}
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"
return $status
}
if ($status.DaysRemaining -le $criticalDays) {
Write-Log ("KRITISCH: Graph-Zertifikat läuft 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 läuft 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 gültig bis {0} ({1} Tage verbleibend)." -f `
$status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss"), $status.DaysRemaining)
}
return $status
}
catch {
Write-Log ("Zertifikatsprüfung 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))
}
return ($parts -join "`r`n`r`n")
}
function New-WorkerConfig {
# Runspaces haben keinen verlässlichen $PSScriptRoot des Hauptskripts.
# Deshalb werden alle Pfade für Worker einmal absolut aufgelöst.
$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
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 für {0} wurde mit Fehler beendet: {1}" -f $worker.RemoteAddress, $_.Exception.Message) "WARN"
}
finally {
try { $worker.PowerShell.Dispose() } catch {}
$script:ActiveSmtpWorkers.RemoveAt($i)
}
}
}
}
function Start-SmtpClientWorker {
param(
[Parameter(Mandatory)]
[System.Net.Sockets.TcpClient]$Client
)
Remove-CompletedSmtpWorkers
$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
}
$ps = [System.Management.Automation.PowerShell]::Create()
$ps.RunspacePool = $script:SmtpRunspacePool
[void]$ps.AddScript($script:SmtpWorkerScript)
[void]$ps.AddArgument($Client)
[void]$ps.AddArgument($script:WorkerConfig)
try {
$async = $ps.BeginInvoke()
[void]$script:ActiveSmtpWorkers.Add([pscustomobject]@{
PowerShell = $ps
AsyncResult = $async
Client = $Client
RemoteAddress = $remoteAddress
StartedUtc = [DateTime]::UtcNow
})
}
catch {
try { $ps.Dispose() } catch {}
try { $Client.Close() } catch {}
throw
}
}
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
# Alte Logarchive beim Start direkt bereinigen.
Invoke-LogMaintenance -Force
# Zertifikat beim Start immer prüfen und Status protokollieren.
[void](Test-RelayCertificateExpiry -ForceLog)
# Prüfintervall optional per config, Standard 12 Stunden.
$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)
# MaxConcurrentClients ist optional, damit bestehende config.json-Dateien unverändert weiterlaufen.
$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 "Ungültiges Smtp.MaxConcurrentClients; verwende Standard 20." "WARN"
}
}
catch {
Write-Log "Smtp.MaxConcurrentClients konnte nicht gelesen werden; verwende Standard 20." "WARN"
}
}
$script:WorkerConfig = New-WorkerConfig
# ---------------------------------------------------------------------------
# SMTP Runspace Pool
# ---------------------------------------------------------------------------
# Nur die Funktionen, die ein SMTP-Client wirklich benötigt, werden in die
# Worker-Runspaces kopiert. Graph-/Queue-Versand bleibt in einem separaten Worker.
$smtpFunctionNames = @(
"Get-LogSettings",
"Invoke-LogMaintenance",
"Rotate-LogIfNeeded",
"Write-Log",
"Resolve-PathFromConfig",
"Test-IPv4InCidr",
"Test-ClientAllowed",
"Get-QueueDirectories",
"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
# ---------------------------------------------------------------------------
# Separater Queue-/Graph-Worker
# ---------------------------------------------------------------------------
# Damit ein langsamer Graph-Aufruf nicht mehr die Annahme neuer SMTP-Verbindungen
# blockiert, läuft Process-Queue dauerhaft in einem eigenen Runspace.
$queueFunctionNames = @(
"Get-LogSettings",
"Invoke-LogMaintenance",
"Rotate-LogIfNeeded",
"Write-Log",
"Resolve-PathFromConfig",
"Get-GraphConnection",
"Set-MimeSender",
"Get-QueueDirectories",
"Get-GraphFailureInfo",
"Get-RetryDecision",
"Move-QueueItem",
"Send-QueuedMail",
"Process-Queue"
)
$queueBootstrap = Get-FunctionBootstrap -FunctionNames $queueFunctionNames
$queueWorkerScript = @"
param(`$Config)
`$script:Config = `$Config
$queueBootstrap
try {
while (`$true) {
try {
Process-Queue
}
catch {
Write-Log ("Queue-Worker: {0}" -f `$_.Exception.Message) "ERROR"
}
Start-Sleep -Seconds ([int]`$script:Config.Queue.PollSeconds)
}
}
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()
# ---------------------------------------------------------------------------
# Listener
# ---------------------------------------------------------------------------
$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 V1.5 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 läuft separat vom SMTP-Listener."
$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 ((Get-Date) -ge $script:NextCertificateCheck) {
[void](Test-RelayCertificateExpiry)
$script:NextCertificateCheck = (Get-Date).AddHours($script:CertificateCheckHours)
}
# Sollte der Queue-Worker unerwartet beendet werden, Relay nicht still
# ohne Versand weiterlaufen lassen.
if ($script:QueueAsyncResult.IsCompleted) {
try {
[void]$script:QueuePowerShell.EndInvoke($script:QueueAsyncResult)
throw "Queue-Worker wurde unerwartet beendet."
}
catch {
throw "Queue-Worker wurde unerwartet beendet: $($_.Exception.Message)"
}
}
if ($listener.Pending()) {
$client = $listener.AcceptTcpClient()
Start-SmtpClientWorker -Client $client
}
else {
Start-Sleep -Milliseconds 100
}
}
}
finally {
Write-Log "SMTPGraphRelay wird beendet..." "INFO"
try { $listener.Stop() } catch {}
# Neue Clients werden nicht mehr angenommen; bestehende Sessions schließen.
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 {}
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 {}
Write-Log "SMTPGraphRelay beendet."
}