2232 lines
74 KiB
PowerShell
2232 lines
74 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.9: gezielte QA-/Failure-Mode-Testhooks; inklusive Failed-Queue, Graceful Shutdown, SMTP AUTH und Backpressure
|
|
#>
|
|
|
|
[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
|
|
$queueId = [IO.Path]::GetFileNameWithoutExtension($FilePath)
|
|
|
|
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]@{
|
|
QueueId = $queueId
|
|
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
|
|
RetryCount = 0
|
|
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
|
}
|
|
}
|
|
|
|
if ($meta -and $meta.PSObject.Properties.Name -contains "QueueId" -and -not [string]::IsNullOrWhiteSpace([string]$meta.QueueId)) {
|
|
$queueId = [string]$meta.QueueId
|
|
}
|
|
|
|
$bytes = [IO.File]::ReadAllBytes($FilePath)
|
|
|
|
if ($script:Config.Graph.ForceSender) {
|
|
$bytes = Set-MimeSender -MimeBytes $bytes -Sender $script:Config.Graph.SenderMailbox
|
|
}
|
|
|
|
try {
|
|
$debug = Get-DebugSettings
|
|
|
|
if ($debug.Enabled -and $debug.DelayBeforeGraphSendSeconds -gt 0) {
|
|
Write-Log ("[{0}] QA: Graph-Versand wird um {1} Sek. verzögert." -f `
|
|
$queueId, $debug.DelayBeforeGraphSendSeconds) "WARN"
|
|
Start-Sleep -Seconds ([int]$debug.DelayBeforeGraphSendSeconds)
|
|
}
|
|
|
|
if ($debug.Enabled -and $debug.SimulateGraphStatus -ge 400) {
|
|
Write-Log ("[{0}] QA: simulierter Graph HTTP {1}." -f `
|
|
$queueId, $debug.SimulateGraphStatus) "WARN"
|
|
throw ("QA simulated Graph HTTP {0}" -f $debug.SimulateGraphStatus)
|
|
}
|
|
|
|
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 ("[{0}] Mail von Graph angenommen (202 Accepted)." -f $queueId)
|
|
|
|
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 ("[{0}] Graph-Versand permanent fehlgeschlagen ({1}, {2}): {3}" -f $queueId, $statusText, $decision.Reason, $failure.Message) "ERROR"
|
|
|
|
$newMeta = [ordered]@{
|
|
QueueId = $queueId
|
|
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 ("[{0}] Graph-Versand nach {1} Versuchen endgültig fehlgeschlagen ({2}): {3}" -f $queueId, $retryCount, $statusText, $failure.Message) "ERROR"
|
|
|
|
$newMeta = [ordered]@{
|
|
QueueId = $queueId
|
|
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]@{
|
|
QueueId = $queueId
|
|
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 ("[{0}] Graph-Versand fehlgeschlagen (Versuch {1}/{2}, {3}, {4}). Neuer Versuch in {5}: {6}" -f `
|
|
$queueId, $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) {
|
|
if ($script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath" -and
|
|
(Test-Path -LiteralPath ([string]$script:Config.ShutdownSignalPath))) {
|
|
Write-Log "Queue-Worker: Shutdown angefordert; starte keine weitere Queue-Mail."
|
|
break
|
|
}
|
|
|
|
$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 New-QueueId {
|
|
# Kurz genug für Logs/SMTP-Antworten, aber mit sehr hoher Kollisionsreserve.
|
|
return ([guid]::NewGuid().ToString("N").Substring(0,12).ToUpperInvariant())
|
|
}
|
|
|
|
function Add-RelayMessageHeaders {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[AllowEmptyCollection()]
|
|
[AllowEmptyString()]
|
|
[System.Collections.Generic.List[string]]$Lines,
|
|
|
|
[Parameter(Mandatory)]
|
|
[string]$QueueId,
|
|
|
|
[Parameter(Mandatory)]
|
|
[string]$RemoteAddress
|
|
)
|
|
|
|
$result = New-Object System.Collections.Generic.List[string]
|
|
|
|
# Header/Body-Grenze suchen. Falls eine kaputte Minimalnachricht keine Leerzeile
|
|
# besitzt, behandeln wir den kompletten Inhalt als Headerblock und ergänzen die
|
|
# notwendige Trennzeile.
|
|
$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
|
|
for ($i = 0; $i -lt $headerEnd; $i++) {
|
|
if ($Lines[$i] -match '^(?i)Message-ID\s*:') {
|
|
$hasMessageId = $true
|
|
break
|
|
}
|
|
}
|
|
|
|
$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"
|
|
|
|
# Received immer ganz oben vor die ursprünglichen Header setzen.
|
|
$result.Add($received)
|
|
|
|
if (-not $hasMessageId) {
|
|
$safeHost = ($hostname -replace '[^A-Za-z0-9._-]', '-')
|
|
$messageId = "<$QueueId.$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))@$safeHost>"
|
|
$result.Add("Message-ID: $messageId")
|
|
}
|
|
|
|
for ($i = 0; $i -lt $Lines.Count; $i++) {
|
|
$result.Add($Lines[$i])
|
|
}
|
|
|
|
if ($separatorIndex -lt 0) {
|
|
$result.Add("")
|
|
}
|
|
|
|
return $result
|
|
}
|
|
|
|
|
|
function Test-AddressInList {
|
|
param(
|
|
[Parameter(Mandatory)][System.Net.IPAddress]$Address,
|
|
[object[]]$Entries
|
|
)
|
|
|
|
foreach ($entry in @($Entries)) {
|
|
if ($null -eq $entry) { continue }
|
|
|
|
$value = [string]$entry
|
|
if ([string]::IsNullOrWhiteSpace($value)) { continue }
|
|
|
|
if ($value -eq "*") { return $true }
|
|
|
|
try {
|
|
if ($value -match '/') {
|
|
if (Test-IPv4InCidr -Address $Address -Cidr $value) {
|
|
return $true
|
|
}
|
|
}
|
|
elseif ([System.Net.IPAddress]::Parse($value).Equals($Address)) {
|
|
return $true
|
|
}
|
|
}
|
|
catch {}
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
function Get-SmtpAuthSettings {
|
|
$requireAuth = $false
|
|
$maxFailures = 5
|
|
$allowUnauthenticatedNetworks = @()
|
|
$users = @()
|
|
|
|
try {
|
|
if ($script:Config.Smtp.PSObject.Properties.Name -contains "RequireAuth") {
|
|
$requireAuth = [bool]$script:Config.Smtp.RequireAuth
|
|
}
|
|
|
|
if ($script:Config.Smtp.PSObject.Properties.Name -contains "AuthMaxFailures") {
|
|
$v = [int]$script:Config.Smtp.AuthMaxFailures
|
|
if ($v -ge 1 -and $v -le 100) {
|
|
$maxFailures = $v
|
|
}
|
|
}
|
|
|
|
if ($script:Config.Smtp.PSObject.Properties.Name -contains "AllowUnauthenticatedNetworks") {
|
|
$allowUnauthenticatedNetworks = @($script:Config.Smtp.AllowUnauthenticatedNetworks)
|
|
}
|
|
|
|
if ($script:Config.Smtp.PSObject.Properties.Name -contains "AuthUsers") {
|
|
$users = @($script:Config.Smtp.AuthUsers)
|
|
}
|
|
}
|
|
catch {}
|
|
|
|
return [pscustomobject]@{
|
|
RequireAuth = $requireAuth
|
|
AuthMaxFailures = $maxFailures
|
|
AllowUnauthenticatedNetworks = $allowUnauthenticatedNetworks
|
|
Users = $users
|
|
}
|
|
}
|
|
|
|
function Test-SmtpAuthenticationRequired {
|
|
param([Parameter(Mandatory)][System.Net.IPAddress]$Address)
|
|
|
|
$settings = Get-SmtpAuthSettings
|
|
|
|
if (-not $settings.RequireAuth) {
|
|
return $false
|
|
}
|
|
|
|
if (Test-AddressInList -Address $Address -Entries $settings.AllowUnauthenticatedNetworks) {
|
|
return $false
|
|
}
|
|
|
|
return $true
|
|
}
|
|
|
|
function ConvertFrom-Base64Utf8 {
|
|
param([Parameter(Mandatory)][string]$Value)
|
|
|
|
try {
|
|
$bytes = [Convert]::FromBase64String($Value)
|
|
return [Text.Encoding]::UTF8.GetString($bytes)
|
|
}
|
|
catch {
|
|
throw "Invalid Base64 data"
|
|
}
|
|
}
|
|
|
|
function Invoke-Pbkdf2Sha256 {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Password,
|
|
[Parameter(Mandatory)][byte[]]$Salt,
|
|
[Parameter(Mandatory)][int]$Iterations,
|
|
[int]$Length = 32
|
|
)
|
|
|
|
if ($Iterations -lt 1) {
|
|
throw "Iterations must be greater than zero."
|
|
}
|
|
|
|
# Auf unterstützten .NET-Framework-Versionen verwenden wir die native,
|
|
# schnelle PBKDF2-SHA256-Implementierung.
|
|
try {
|
|
$derive = New-Object System.Security.Cryptography.Rfc2898DeriveBytes(
|
|
$Password,
|
|
$Salt,
|
|
$Iterations,
|
|
[System.Security.Cryptography.HashAlgorithmName]::SHA256
|
|
)
|
|
|
|
try {
|
|
return $derive.GetBytes($Length)
|
|
}
|
|
finally {
|
|
$derive.Dispose()
|
|
}
|
|
}
|
|
catch {
|
|
# Kompatibilitäts-Fallback für ältere .NET-Framework-Stände.
|
|
$hmac = New-Object System.Security.Cryptography.HMACSHA256
|
|
$hmac.Key = [Text.Encoding]::UTF8.GetBytes($Password)
|
|
|
|
try {
|
|
$hashLength = 32
|
|
$blocks = [Math]::Ceiling($Length / [double]$hashLength)
|
|
$output = New-Object byte[] ($blocks * $hashLength)
|
|
$offset = 0
|
|
|
|
for ($block = 1; $block -le $blocks; $block++) {
|
|
$blockBytes = [BitConverter]::GetBytes([int]$block)
|
|
if ([BitConverter]::IsLittleEndian) {
|
|
[Array]::Reverse($blockBytes)
|
|
}
|
|
|
|
$input = New-Object byte[] ($Salt.Length + 4)
|
|
[Array]::Copy($Salt, 0, $input, 0, $Salt.Length)
|
|
[Array]::Copy($blockBytes, 0, $input, $Salt.Length, 4)
|
|
|
|
$u = $hmac.ComputeHash($input)
|
|
$t = New-Object byte[] $u.Length
|
|
[Array]::Copy($u, $t, $u.Length)
|
|
|
|
for ($i = 2; $i -le $Iterations; $i++) {
|
|
$u = $hmac.ComputeHash($u)
|
|
for ($j = 0; $j -lt $t.Length; $j++) {
|
|
$t[$j] = $t[$j] -bxor $u[$j]
|
|
}
|
|
}
|
|
|
|
[Array]::Copy($t, 0, $output, $offset, $t.Length)
|
|
$offset += $t.Length
|
|
}
|
|
|
|
$result = New-Object byte[] $Length
|
|
[Array]::Copy($output, 0, $result, 0, $Length)
|
|
return $result
|
|
}
|
|
finally {
|
|
$hmac.Dispose()
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-FixedTimeEquals {
|
|
param(
|
|
[Parameter(Mandatory)][byte[]]$A,
|
|
[Parameter(Mandatory)][byte[]]$B
|
|
)
|
|
|
|
if ($A.Length -ne $B.Length) {
|
|
return $false
|
|
}
|
|
|
|
[int]$difference = 0
|
|
for ($i = 0; $i -lt $A.Length; $i++) {
|
|
$difference = $difference -bor ($A[$i] -bxor $B[$i])
|
|
}
|
|
|
|
return ($difference -eq 0)
|
|
}
|
|
|
|
function Test-SmtpCredentials {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Username,
|
|
[Parameter(Mandatory)][string]$Password
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($Username)) {
|
|
return $false
|
|
}
|
|
|
|
$settings = Get-SmtpAuthSettings
|
|
$user = $settings.Users |
|
|
Where-Object { ([string]$_.Username).Equals($Username, [StringComparison]::OrdinalIgnoreCase) } |
|
|
Select-Object -First 1
|
|
|
|
if (-not $user) {
|
|
# Bewusst keine Unterscheidung im SMTP-Result zwischen unbekanntem
|
|
# Benutzer und falschem Passwort.
|
|
return $false
|
|
}
|
|
|
|
try {
|
|
$salt = [Convert]::FromBase64String([string]$user.Salt)
|
|
$expected = [Convert]::FromBase64String([string]$user.PasswordHash)
|
|
$iterations = [int]$user.Iterations
|
|
|
|
if ($iterations -lt 10000 -or $expected.Length -lt 16 -or $salt.Length -lt 8) {
|
|
return $false
|
|
}
|
|
|
|
$actual = Invoke-Pbkdf2Sha256 `
|
|
-Password $Password `
|
|
-Salt $salt `
|
|
-Iterations $iterations `
|
|
-Length $expected.Length
|
|
|
|
return (Test-FixedTimeEquals -A $actual -B $expected)
|
|
}
|
|
catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Invoke-SmtpAuthLogin {
|
|
param(
|
|
[Parameter(Mandatory)][System.IO.StreamReader]$Reader,
|
|
[Parameter(Mandatory)][System.IO.StreamWriter]$Writer,
|
|
[string]$InitialResponse
|
|
)
|
|
|
|
try {
|
|
if ([string]::IsNullOrWhiteSpace($InitialResponse)) {
|
|
Write-SmtpLine $Writer "334 VXNlcm5hbWU6"
|
|
$encodedUser = $Reader.ReadLine()
|
|
if ($null -eq $encodedUser) { throw "Client disconnected during AUTH LOGIN" }
|
|
}
|
|
else {
|
|
$encodedUser = $InitialResponse
|
|
}
|
|
|
|
$username = ConvertFrom-Base64Utf8 -Value $encodedUser
|
|
|
|
Write-SmtpLine $Writer "334 UGFzc3dvcmQ6"
|
|
$encodedPassword = $Reader.ReadLine()
|
|
if ($null -eq $encodedPassword) { throw "Client disconnected during AUTH LOGIN" }
|
|
|
|
$password = ConvertFrom-Base64Utf8 -Value $encodedPassword
|
|
|
|
return [pscustomobject]@{
|
|
Valid = (Test-SmtpCredentials -Username $username -Password $password)
|
|
Username = $username
|
|
ProtocolError = $false
|
|
}
|
|
}
|
|
catch {
|
|
return [pscustomobject]@{
|
|
Valid = $false
|
|
Username = $null
|
|
ProtocolError = $true
|
|
}
|
|
}
|
|
}
|
|
|
|
function Invoke-SmtpAuthPlain {
|
|
param(
|
|
[Parameter(Mandatory)][System.IO.StreamReader]$Reader,
|
|
[Parameter(Mandatory)][System.IO.StreamWriter]$Writer,
|
|
[string]$InitialResponse
|
|
)
|
|
|
|
try {
|
|
$encoded = $InitialResponse
|
|
|
|
if ([string]::IsNullOrWhiteSpace($encoded)) {
|
|
Write-SmtpLine $Writer "334"
|
|
$encoded = $Reader.ReadLine()
|
|
if ($null -eq $encoded) { throw "Client disconnected during AUTH PLAIN" }
|
|
}
|
|
|
|
$decoded = ConvertFrom-Base64Utf8 -Value $encoded
|
|
$parts = $decoded -split "`0", 3
|
|
|
|
if ($parts.Count -lt 3) {
|
|
throw "Invalid AUTH PLAIN payload"
|
|
}
|
|
|
|
# RFC 4616: [authzid] NUL authcid NUL passwd
|
|
$username = $parts[1]
|
|
$password = $parts[2]
|
|
|
|
return [pscustomobject]@{
|
|
Valid = (Test-SmtpCredentials -Username $username -Password $password)
|
|
Username = $username
|
|
ProtocolError = $false
|
|
}
|
|
}
|
|
catch {
|
|
return [pscustomobject]@{
|
|
Valid = $false
|
|
Username = $null
|
|
ProtocolError = $true
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function Get-DebugSettings {
|
|
$defaults = [ordered]@{
|
|
Enabled = $false
|
|
DelayBeforeGraphSendSeconds = 0
|
|
SimulateGraphStatus = 0
|
|
SimulateQueueWriteFailure = $false
|
|
OverridePendingCount = -1
|
|
OverrideFreeDiskSpaceMB = -1
|
|
}
|
|
|
|
try {
|
|
if (-not ($script:Config.PSObject.Properties.Name -contains "Debug") -or -not $script:Config.Debug) {
|
|
return [pscustomobject]$defaults
|
|
}
|
|
|
|
$d = $script:Config.Debug
|
|
|
|
if ($d.PSObject.Properties.Name -contains "Enabled") {
|
|
$defaults.Enabled = [bool]$d.Enabled
|
|
}
|
|
|
|
if ($d.PSObject.Properties.Name -contains "DelayBeforeGraphSendSeconds") {
|
|
$v = [int]$d.DelayBeforeGraphSendSeconds
|
|
if ($v -ge 0 -and $v -le 300) { $defaults.DelayBeforeGraphSendSeconds = $v }
|
|
}
|
|
|
|
if ($d.PSObject.Properties.Name -contains "SimulateGraphStatus") {
|
|
$v = [int]$d.SimulateGraphStatus
|
|
if ($v -eq 0 -or ($v -ge 400 -and $v -le 599)) { $defaults.SimulateGraphStatus = $v }
|
|
}
|
|
|
|
if ($d.PSObject.Properties.Name -contains "SimulateQueueWriteFailure") {
|
|
$defaults.SimulateQueueWriteFailure = [bool]$d.SimulateQueueWriteFailure
|
|
}
|
|
|
|
if ($d.PSObject.Properties.Name -contains "OverridePendingCount") {
|
|
$v = [int]$d.OverridePendingCount
|
|
if ($v -ge -1 -and $v -le 1000000) { $defaults.OverridePendingCount = $v }
|
|
}
|
|
|
|
if ($d.PSObject.Properties.Name -contains "OverrideFreeDiskSpaceMB") {
|
|
$v = [int]$d.OverrideFreeDiskSpaceMB
|
|
if ($v -ge -1 -and $v -le 1048576) { $defaults.OverrideFreeDiskSpaceMB = $v }
|
|
}
|
|
}
|
|
catch {}
|
|
|
|
return [pscustomobject]$defaults
|
|
}
|
|
|
|
function Get-SmtpLimits {
|
|
$maxRecipients = 50
|
|
$maxMessagesPerConnection = 25
|
|
$maxPendingMessages = 5000
|
|
$minFreeDiskSpaceMB = 1024
|
|
|
|
try {
|
|
if ($script:Config.Smtp.PSObject.Properties.Name -contains "MaxRecipients") {
|
|
$v = [int]$script:Config.Smtp.MaxRecipients
|
|
if ($v -ge 1 -and $v -le 1000) { $maxRecipients = $v }
|
|
}
|
|
|
|
if ($script:Config.Smtp.PSObject.Properties.Name -contains "MaxMessagesPerConnection") {
|
|
$v = [int]$script:Config.Smtp.MaxMessagesPerConnection
|
|
if ($v -ge 1 -and $v -le 10000) { $maxMessagesPerConnection = $v }
|
|
}
|
|
|
|
if ($script:Config.Queue.PSObject.Properties.Name -contains "MaxPendingMessages") {
|
|
$v = [int]$script:Config.Queue.MaxPendingMessages
|
|
if ($v -ge 1 -and $v -le 1000000) { $maxPendingMessages = $v }
|
|
}
|
|
|
|
if ($script:Config.Queue.PSObject.Properties.Name -contains "MinFreeDiskSpaceMB") {
|
|
$v = [int]$script:Config.Queue.MinFreeDiskSpaceMB
|
|
if ($v -ge 0 -and $v -le 1048576) { $minFreeDiskSpaceMB = $v }
|
|
}
|
|
}
|
|
catch {}
|
|
|
|
return [pscustomobject]@{
|
|
MaxRecipients = $maxRecipients
|
|
MaxMessagesPerConnection = $maxMessagesPerConnection
|
|
MaxPendingMessages = $maxPendingMessages
|
|
MinFreeDiskSpaceMB = $minFreeDiskSpaceMB
|
|
}
|
|
}
|
|
|
|
function Get-QueuePressure {
|
|
$dirs = Get-QueueDirectories
|
|
$limits = Get-SmtpLimits
|
|
|
|
$debug = Get-DebugSettings
|
|
|
|
try {
|
|
if ($debug.Enabled -and $debug.OverridePendingCount -ge 0) {
|
|
$pendingCount = [int]$debug.OverridePendingCount
|
|
Write-Log ("QA: PendingCount wird mit {0} simuliert." -f $pendingCount) "WARN"
|
|
}
|
|
else {
|
|
$pendingCount = @(
|
|
Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File -ErrorAction Stop
|
|
).Count
|
|
}
|
|
}
|
|
catch {
|
|
return [pscustomobject]@{
|
|
Accept = $false
|
|
SmtpCode = "451 4.3.0 SMTPGraphRelay queue temporarily unavailable"
|
|
Reason = "Pending-Queue konnte nicht gelesen werden: $($_.Exception.Message)"
|
|
PendingCount = $null
|
|
FreeSpaceMB = $null
|
|
}
|
|
}
|
|
|
|
if ($pendingCount -ge $limits.MaxPendingMessages) {
|
|
return [pscustomobject]@{
|
|
Accept = $false
|
|
SmtpCode = "452 4.3.1 SMTPGraphRelay queue limit reached"
|
|
Reason = "Pending-Queue-Limit erreicht ($pendingCount/$($limits.MaxPendingMessages))"
|
|
PendingCount = $pendingCount
|
|
FreeSpaceMB = $null
|
|
}
|
|
}
|
|
|
|
$freeSpaceMB = $null
|
|
|
|
try {
|
|
$fullQueuePath = [IO.Path]::GetFullPath($dirs.Root)
|
|
$root = [IO.Path]::GetPathRoot($fullQueuePath)
|
|
|
|
if ($root) {
|
|
$driveInfo = New-Object System.IO.DriveInfo($root)
|
|
if ($driveInfo.IsReady) {
|
|
if ($debug.Enabled -and $debug.OverrideFreeDiskSpaceMB -ge 0) {
|
|
$freeSpaceMB = [int]$debug.OverrideFreeDiskSpaceMB
|
|
Write-Log ("QA: freier Speicher wird mit {0} MB simuliert." -f $freeSpaceMB) "WARN"
|
|
}
|
|
else {
|
|
$freeSpaceMB = [Math]::Floor($driveInfo.AvailableFreeSpace / 1MB)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
return [pscustomobject]@{
|
|
Accept = $false
|
|
SmtpCode = "451 4.3.0 SMTPGraphRelay storage status unavailable"
|
|
Reason = "Freier Speicher konnte nicht ermittelt werden: $($_.Exception.Message)"
|
|
PendingCount = $pendingCount
|
|
FreeSpaceMB = $null
|
|
}
|
|
}
|
|
|
|
if ($null -ne $freeSpaceMB -and $freeSpaceMB -lt $limits.MinFreeDiskSpaceMB) {
|
|
return [pscustomobject]@{
|
|
Accept = $false
|
|
SmtpCode = "452 4.3.1 Insufficient system storage"
|
|
Reason = "Freier Speicher zu niedrig (${freeSpaceMB}MB < $($limits.MinFreeDiskSpaceMB)MB)"
|
|
PendingCount = $pendingCount
|
|
FreeSpaceMB = $freeSpaceMB
|
|
}
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
Accept = $true
|
|
SmtpCode = $null
|
|
Reason = "OK"
|
|
PendingCount = $pendingCount
|
|
FreeSpaceMB = $freeSpaceMB
|
|
}
|
|
}
|
|
|
|
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,
|
|
|
|
[string]$AuthenticatedUser,
|
|
|
|
[string]$QueueId
|
|
)
|
|
|
|
$dirs = Get-QueueDirectories
|
|
|
|
if ([string]::IsNullOrWhiteSpace($QueueId)) {
|
|
$QueueId = New-QueueId
|
|
}
|
|
|
|
$incomingEmlTmp = Join-Path $dirs.Incoming "$QueueId.eml.tmp"
|
|
$incomingMetaTmp = Join-Path $dirs.Incoming "$QueueId.eml.json.tmp"
|
|
|
|
$pendingEml = Join-Path $dirs.Pending "$QueueId.eml"
|
|
$pendingMeta = "$pendingEml.json"
|
|
|
|
$messageLines = Add-RelayMessageHeaders `
|
|
-Lines $Lines `
|
|
-QueueId $QueueId `
|
|
-RemoteAddress $RemoteAddress
|
|
|
|
# SMTP DATA wird in CRLF normalisiert.
|
|
$raw = ($messageLines -join "`r`n") + "`r`n"
|
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
|
|
$meta = [ordered]@{
|
|
QueueId = $QueueId
|
|
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
|
|
RemoteAddress = $RemoteAddress
|
|
AuthenticatedUser = $AuthenticatedUser
|
|
EnvelopeFrom = $MailFrom
|
|
EnvelopeRecipients = @($Recipients)
|
|
RetryCount = 0
|
|
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
|
}
|
|
|
|
try {
|
|
$debug = Get-DebugSettings
|
|
if ($debug.Enabled -and $debug.SimulateQueueWriteFailure) {
|
|
Write-Log ("[{0}] QA: simulierter Queue-Schreibfehler." -f $QueueId) "WARN"
|
|
throw "QA simulated queue write failure"
|
|
}
|
|
|
|
# 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
|
|
}
|
|
|
|
$authText = if ([string]::IsNullOrWhiteSpace($AuthenticatedUser)) { "unauthenticated" } else { $AuthenticatedUser }
|
|
Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress | Auth=$authText"
|
|
return [pscustomobject]@{
|
|
QueueId = $QueueId
|
|
Path = $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]
|
|
$acceptedMessages = 0
|
|
$limits = Get-SmtpLimits
|
|
$authSettings = Get-SmtpAuthSettings
|
|
|
|
$authenticated = $false
|
|
$authenticatedUser = $null
|
|
$authFailures = 0
|
|
$authLocked = $false
|
|
$authRequired = Test-SmtpAuthenticationRequired -Address $remoteIp
|
|
$authAvailable = (@($authSettings.Users).Count -gt 0)
|
|
|
|
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\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))
|
|
|
|
if ($authAvailable) {
|
|
Write-SmtpLine $writer "250-AUTH LOGIN PLAIN"
|
|
}
|
|
|
|
Write-SmtpLine $writer "250 8BITMIME"
|
|
}
|
|
elseif ($line -match '^(?i)HELO\s+(.+)$') {
|
|
Write-SmtpLine $writer ("250 {0}" -f $script:Config.Smtp.Hostname)
|
|
}
|
|
elseif ($line -match '^(?i)AUTH\s+LOGIN(?:\s+(\S+))?\s*$') {
|
|
if (-not $authAvailable) {
|
|
Write-SmtpLine $writer "504 5.7.4 Authentication mechanism unavailable"
|
|
continue
|
|
}
|
|
|
|
if ($authenticated) {
|
|
Write-SmtpLine $writer "503 5.5.0 Already authenticated"
|
|
continue
|
|
}
|
|
|
|
if ($authLocked) {
|
|
Write-SmtpLine $writer "454 4.7.0 Too many authentication failures"
|
|
continue
|
|
}
|
|
|
|
$initial = $matches[1]
|
|
$result = Invoke-SmtpAuthLogin -Reader $reader -Writer $writer -InitialResponse $initial
|
|
|
|
if ($result.ProtocolError) {
|
|
$authFailures++
|
|
Write-SmtpLine $writer "501 5.5.2 Invalid authentication data"
|
|
}
|
|
elseif ($result.Valid) {
|
|
$authenticated = $true
|
|
$authenticatedUser = $result.Username
|
|
$authFailures = 0
|
|
Write-SmtpLine $writer "235 2.7.0 Authentication successful"
|
|
Write-Log ("SMTP-AUTH LOGIN erfolgreich | Client={0} | Benutzer={1}" -f $remoteIp, $authenticatedUser)
|
|
continue
|
|
}
|
|
else {
|
|
$authFailures++
|
|
Write-SmtpLine $writer "535 5.7.8 Authentication credentials invalid"
|
|
Write-Log ("SMTP-AUTH LOGIN fehlgeschlagen | Client={0} | Benutzer={1}" -f $remoteIp, $result.Username) "WARN"
|
|
}
|
|
|
|
if ($authFailures -ge $authSettings.AuthMaxFailures) {
|
|
$authLocked = $true
|
|
Write-Log ("SMTP-AUTH gesperrt nach {0} Fehlversuchen | Client={1}" -f $authFailures, $remoteIp) "WARN"
|
|
}
|
|
}
|
|
elseif ($line -match '^(?i)AUTH\s+PLAIN(?:\s+(\S+))?\s*$') {
|
|
if (-not $authAvailable) {
|
|
Write-SmtpLine $writer "504 5.7.4 Authentication mechanism unavailable"
|
|
continue
|
|
}
|
|
|
|
if ($authenticated) {
|
|
Write-SmtpLine $writer "503 5.5.0 Already authenticated"
|
|
continue
|
|
}
|
|
|
|
if ($authLocked) {
|
|
Write-SmtpLine $writer "454 4.7.0 Too many authentication failures"
|
|
continue
|
|
}
|
|
|
|
$initial = $matches[1]
|
|
$result = Invoke-SmtpAuthPlain -Reader $reader -Writer $writer -InitialResponse $initial
|
|
|
|
if ($result.ProtocolError) {
|
|
$authFailures++
|
|
Write-SmtpLine $writer "501 5.5.2 Invalid authentication data"
|
|
}
|
|
elseif ($result.Valid) {
|
|
$authenticated = $true
|
|
$authenticatedUser = $result.Username
|
|
$authFailures = 0
|
|
Write-SmtpLine $writer "235 2.7.0 Authentication successful"
|
|
Write-Log ("SMTP-AUTH PLAIN erfolgreich | Client={0} | Benutzer={1}" -f $remoteIp, $authenticatedUser)
|
|
continue
|
|
}
|
|
else {
|
|
$authFailures++
|
|
Write-SmtpLine $writer "535 5.7.8 Authentication credentials invalid"
|
|
Write-Log ("SMTP-AUTH PLAIN fehlgeschlagen | Client={0} | Benutzer={1}" -f $remoteIp, $result.Username) "WARN"
|
|
}
|
|
|
|
if ($authFailures -ge $authSettings.AuthMaxFailures) {
|
|
$authLocked = $true
|
|
Write-Log ("SMTP-AUTH gesperrt nach {0} Fehlversuchen | Client={1}" -f $authFailures, $remoteIp) "WARN"
|
|
}
|
|
}
|
|
elseif ($line -match '^(?i)AUTH\b') {
|
|
Write-SmtpLine $writer "504 5.5.4 Unsupported authentication mechanism"
|
|
}
|
|
elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') {
|
|
if ($authRequired -and -not $authenticated) {
|
|
Write-SmtpLine $writer "530 5.7.0 Authentication required"
|
|
continue
|
|
}
|
|
|
|
if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) {
|
|
Write-SmtpLine $writer "452 4.5.3 Too many messages in this session"
|
|
Write-Log ("SMTP-Session von {0}: Nachrichtenlimit {1} erreicht." -f $remoteIp, $limits.MaxMessagesPerConnection) "WARN"
|
|
continue
|
|
}
|
|
|
|
$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
|
|
}
|
|
|
|
if ($recipients.Count -ge $limits.MaxRecipients) {
|
|
Write-SmtpLine $writer "452 4.5.3 Too many recipients"
|
|
Write-Log ("SMTP-Session von {0}: Empfängerlimit {1} erreicht." -f $remoteIp, $limits.MaxRecipients) "WARN"
|
|
continue
|
|
}
|
|
|
|
$recipients.Add($matches[1])
|
|
Write-SmtpLine $writer "250 2.1.5 OK"
|
|
}
|
|
elseif ($line -match '^(?i)DATA\s*$') {
|
|
if ($authRequired -and -not $authenticated) {
|
|
Write-SmtpLine $writer "530 5.7.0 Authentication required"
|
|
continue
|
|
}
|
|
|
|
if (-not $mailFrom -or $recipients.Count -eq 0) {
|
|
Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM and RCPT TO first"
|
|
continue
|
|
}
|
|
|
|
if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) {
|
|
Write-SmtpLine $writer "452 4.5.3 Too many messages in this session"
|
|
continue
|
|
}
|
|
|
|
$pressure = Get-QueuePressure
|
|
if (-not $pressure.Accept) {
|
|
Write-SmtpLine $writer $pressure.SmtpCode
|
|
Write-Log ("SMTP-Backpressure für {0}: {1}" -f $remoteIp, $pressure.Reason) "WARN"
|
|
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
|
|
}
|
|
|
|
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 {
|
|
$queueId = New-QueueId
|
|
|
|
try {
|
|
$saved = Save-SmtpMessage `
|
|
-Lines $data `
|
|
-MailFrom $mailFrom `
|
|
-Recipients $recipients.ToArray() `
|
|
-RemoteAddress $remoteIp.ToString() `
|
|
-AuthenticatedUser $authenticatedUser `
|
|
-QueueId $queueId
|
|
|
|
$acceptedMessages++
|
|
|
|
Write-SmtpLine $writer (
|
|
"250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId
|
|
)
|
|
}
|
|
catch {
|
|
Write-SmtpLine $writer "451 4.3.0 SMTPGraphRelay queue temporarily unavailable"
|
|
|
|
Write-Log (
|
|
"[{0}] Queue-Speicherung fehlgeschlagen | Client={1} | Fehler={2}" -f `
|
|
$queueId,
|
|
$remoteIp,
|
|
$_.Exception.Message
|
|
) "ERROR"
|
|
}
|
|
}
|
|
|
|
$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)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
|
|
|
|
if ($script:ShutdownSignalPath) {
|
|
$copy | Add-Member -NotePropertyName ShutdownSignalPath -NotePropertyValue $script:ShutdownSignalPath -Force
|
|
}
|
|
|
|
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
|
|
|
|
$script:InstallRoot = Split-Path -Parent ([IO.Path]::GetFullPath($ConfigPath))
|
|
$script:ShutdownSignalPath = Join-Path $script:InstallRoot "shutdown.request"
|
|
$script:GracefulShutdownSeconds = 30
|
|
|
|
if ($script:Config.Smtp.PSObject.Properties.Name -contains "GracefulShutdownSeconds") {
|
|
try {
|
|
$configuredGrace = [int]$script:Config.Smtp.GracefulShutdownSeconds
|
|
if ($configuredGrace -ge 5 -and $configuredGrace -le 300) {
|
|
$script:GracefulShutdownSeconds = $configuredGrace
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
# Ein altes Signal darf einen frisch gestarteten Relay nicht sofort wieder beenden.
|
|
Remove-Item -LiteralPath $script:ShutdownSignalPath -Force -ErrorAction SilentlyContinue
|
|
|
|
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",
|
|
"New-QueueId",
|
|
"Add-RelayMessageHeaders",
|
|
"Test-AddressInList",
|
|
"Get-SmtpAuthSettings",
|
|
"Test-SmtpAuthenticationRequired",
|
|
"ConvertFrom-Base64Utf8",
|
|
"Invoke-Pbkdf2Sha256",
|
|
"Test-FixedTimeEquals",
|
|
"Test-SmtpCredentials",
|
|
"Invoke-SmtpAuthLogin",
|
|
"Invoke-SmtpAuthPlain",
|
|
"Get-DebugSettings",
|
|
"Get-SmtpLimits",
|
|
"Get-QueuePressure",
|
|
"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",
|
|
"Get-DebugSettings",
|
|
"Move-QueueItem",
|
|
"Send-QueuedMail",
|
|
"Process-Queue"
|
|
)
|
|
|
|
$queueBootstrap = Get-FunctionBootstrap -FunctionNames $queueFunctionNames
|
|
$queueWorkerScript = @"
|
|
param(`$Config)
|
|
`$script:Config = `$Config
|
|
|
|
$queueBootstrap
|
|
|
|
try {
|
|
while (`$true) {
|
|
if (`$script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath" -and
|
|
(Test-Path -LiteralPath ([string]`$script:Config.ShutdownSignalPath))) {
|
|
Write-Log "Queue-Worker: Shutdown-Signal erkannt."
|
|
break
|
|
}
|
|
|
|
try {
|
|
Process-Queue
|
|
}
|
|
catch {
|
|
Write-Log ("Queue-Worker: {0}" -f `$_.Exception.Message) "ERROR"
|
|
}
|
|
|
|
# In kleinen Schritten schlafen, damit Shutdown nicht erst nach PollSeconds reagiert.
|
|
`$sleepUntil = (Get-Date).AddSeconds([int]`$script:Config.Queue.PollSeconds)
|
|
while ((Get-Date) -lt `$sleepUntil) {
|
|
if (`$script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath" -and
|
|
(Test-Path -LiteralPath ([string]`$script:Config.ShutdownSignalPath))) {
|
|
break
|
|
}
|
|
Start-Sleep -Milliseconds 250
|
|
}
|
|
}
|
|
}
|
|
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.9 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."
|
|
|
|
$limits = Get-SmtpLimits
|
|
Write-Log ("SMTP-Limits: max. {0} Empfänger/Mail, {1} Mails/Verbindung." -f `
|
|
$limits.MaxRecipients, $limits.MaxMessagesPerConnection)
|
|
Write-Log ("Backpressure: max. {0} Pending-Mails, mindestens {1} MB freier Speicher." -f `
|
|
$limits.MaxPendingMessages, $limits.MinFreeDiskSpaceMB)
|
|
|
|
$authSettings = Get-SmtpAuthSettings
|
|
$authMode = if ($authSettings.RequireAuth) { "erforderlich" } else { "optional/deaktiviert" }
|
|
Write-Log ("SMTP-AUTH: {0}; {1} Benutzer; max. {2} Fehlversuche/Verbindung." -f `
|
|
$authMode, @($authSettings.Users).Count, $authSettings.AuthMaxFailures)
|
|
|
|
Write-Log ("Graceful Shutdown: bis zu {0} Sekunden für aktive Sessions/Worker." -f `
|
|
$script:GracefulShutdownSeconds)
|
|
|
|
$debugSettings = Get-DebugSettings
|
|
if ($debugSettings.Enabled) {
|
|
Write-Log "ACHTUNG: QA-/Debug-Testmodus ist AKTIV." "WARN"
|
|
}
|
|
|
|
$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 (Test-Path -LiteralPath $script:ShutdownSignalPath) {
|
|
Write-Log "Graceful Shutdown angefordert. SMTP-Listener wird geschlossen."
|
|
try { $listener.Stop() } catch {}
|
|
break
|
|
}
|
|
|
|
if ((Get-Date) -ge $script:NextCertificateCheck) {
|
|
[void](Test-RelayCertificateExpiry)
|
|
$script:NextCertificateCheck = (Get-Date).AddHours($script:CertificateCheckHours)
|
|
}
|
|
|
|
# Sollte der Queue-Worker unerwartet beendet werden, ist das nur dann ein
|
|
# Fehler, wenn gerade KEIN geplanter Shutdown läuft.
|
|
if ($script:QueueAsyncResult.IsCompleted) {
|
|
if (Test-Path -LiteralPath $script:ShutdownSignalPath) {
|
|
break
|
|
}
|
|
|
|
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 fährt kontrolliert herunter..."
|
|
|
|
try { $listener.Stop() } catch {}
|
|
|
|
$deadline = (Get-Date).AddSeconds($script:GracefulShutdownSeconds)
|
|
|
|
# Laufende SMTP-Sessions bekommen Zeit, ihre aktuelle Nachricht/Session sauber
|
|
# abzuschließen. Es werden bereits keine neuen Verbindungen mehr angenommen.
|
|
while ($script:ActiveSmtpWorkers.Count -gt 0 -and (Get-Date) -lt $deadline) {
|
|
Remove-CompletedSmtpWorkers
|
|
|
|
if ($script:ActiveSmtpWorkers.Count -gt 0) {
|
|
Start-Sleep -Milliseconds 200
|
|
}
|
|
}
|
|
|
|
if ($script:ActiveSmtpWorkers.Count -gt 0) {
|
|
Write-Log ("Graceful-Shutdown-Timeout: {0} SMTP-Session(s) werden jetzt beendet." -f `
|
|
$script:ActiveSmtpWorkers.Count) "WARN"
|
|
|
|
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 {}
|
|
|
|
# Queue-Worker darf den aktuell laufenden Graph-Aufruf beenden. Durch das
|
|
# Shutdown-Signal startet Process-Queue anschließend keine weitere Mail.
|
|
while (-not $script:QueueAsyncResult.IsCompleted -and (Get-Date) -lt $deadline) {
|
|
Start-Sleep -Milliseconds 200
|
|
}
|
|
|
|
if (-not $script:QueueAsyncResult.IsCompleted) {
|
|
Write-Log "Graceful-Shutdown-Timeout: Queue-/Graph-Worker wird jetzt beendet." "WARN"
|
|
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 {}
|
|
|
|
Remove-Item -LiteralPath $script:ShutdownSignalPath -Force -ErrorAction SilentlyContinue
|
|
Write-Log "SMTPGraphRelay sauber beendet."
|
|
}
|