652 lines
27 KiB
PowerShell
652 lines
27 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[string]$ConfigPath = "$PSScriptRoot\config.json"
|
|
)
|
|
|
|
#Requires -Version 5.1
|
|
#Requires -RunAsAdministrator
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
|
|
function Write-Log {
|
|
param([string]$Message, [string]$Level = "INFO")
|
|
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
|
|
$line = "[$timestamp] [$Level] $Message"
|
|
Write-Host $line
|
|
|
|
try {
|
|
if ($script:Config -and $script:Config.Paths -and $script:Config.Paths.Logs) {
|
|
$logDir = Resolve-PathFromConfig $script:Config.Paths.Logs
|
|
if (-not (Test-Path -LiteralPath $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
|
|
$logFile = Join-Path $logDir ("relay-{0}.log" -f (Get-Date -Format "yyyyMMdd"))
|
|
Add-Content -LiteralPath $logFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function Resolve-PathFromConfig {
|
|
param([string]$Path)
|
|
if ([IO.Path]::IsPathRooted($Path)) { return $Path }
|
|
return (Join-Path $PSScriptRoot $Path)
|
|
}
|
|
|
|
function Get-QueueDirectories {
|
|
$qPath = Resolve-PathFromConfig $script:Config.Paths.Queue
|
|
return [pscustomobject]@{
|
|
Incoming = (Join-Path $qPath "incoming")
|
|
Pending = (Join-Path $qPath "pending")
|
|
Processing = (Join-Path $qPath "processing")
|
|
}
|
|
}
|
|
|
|
function New-QueueId {
|
|
return [guid]::NewGuid().ToString("N")
|
|
}
|
|
|
|
function Test-FixedTimeEquals {
|
|
param([byte[]]$A, [byte[]]$B)
|
|
if ($A -eq $null -or $B -eq $null -or $A.Length -ne $B.Length) { return $false }
|
|
$diff = 0
|
|
for ($i = 0; $i -lt $A.Length; $i++) { $diff = $diff -bxor ($A[$i] -bxor $B[$i]) }
|
|
return ($diff -eq 0)
|
|
}
|
|
|
|
function Verify-SmtpCredentials {
|
|
param([string]$Username, [string]$PlainPassword)
|
|
if (-not $script:Config.Smtp.AuthUsers) { return $false }
|
|
|
|
foreach ($u in $script:Config.Smtp.AuthUsers) {
|
|
if ([string]$u.Username -eq $Username) {
|
|
try {
|
|
$salt = [Convert]::FromBase64String($u.Salt)
|
|
$expectedHash = [Convert]::FromBase64String($u.PasswordHash)
|
|
$iterations = [int]$u.Iterations
|
|
|
|
$derive = New-Object System.Security.Cryptography.Rfc2898DeriveBytes($PlainPassword, $salt, $iterations, [System.Security.Cryptography.HashAlgorithmName]::SHA256)
|
|
$actualHash = $derive.GetBytes($expectedHash.Length)
|
|
$derive.Dispose()
|
|
|
|
return Test-FixedTimeEquals $actualHash $expectedHash
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-ClientAllowed {
|
|
param([string]$IpAddress)
|
|
$ip = [System.IPAddress]::Parse($IpAddress)
|
|
|
|
if ($ip.IPAddressToString -eq "127.0.0.1" -or $ip.IPAddressToString -eq "::1") { return $true }
|
|
|
|
if ($script:Config.Smtp.AllowedNetworks) {
|
|
foreach ($net in $script:Config.Smtp.AllowedNetworks) {
|
|
if (Test-IpInCidr $ip $net) { return $true }
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-IpInCidr {
|
|
param([System.IPAddress]$Ip, [string]$Cidr)
|
|
try {
|
|
$parts = $Cidr.Split('/')
|
|
$netAddr = [System.IPAddress]::Parse($parts[0])
|
|
$prefixLength = [int]$parts[1]
|
|
|
|
$ipBytes = $Ip.GetAddressBytes()
|
|
$netBytes = $netAddr.GetAddressBytes()
|
|
|
|
if ($ipBytes.Length -ne $netBytes.Length) { return $false }
|
|
|
|
$bytesToCheck = [Math]::Floor($prefixLength / 8)
|
|
$bitsToCheck = $prefixLength % 8
|
|
|
|
for ($i = 0; $i -lt $bytesToCheck; $i++) {
|
|
if ($ipBytes[$i] -ne $netBytes[$i]) { return $false }
|
|
}
|
|
|
|
if ($bitsToCheck -gt 0) {
|
|
$mask = [byte](256 - [Math]::Pow(2, (8 - $bitsToCheck)))
|
|
if (($ipBytes[$bytesToCheck] -band $mask) -ne ($netBytes[$bytesToCheck] -band $mask)) { return $false }
|
|
}
|
|
return $true
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Test-UnauthenticatedNetworkAllowed {
|
|
param([string]$IpAddress)
|
|
if (-not $script:Config.Smtp.AllowUnauthenticatedNetworks) { return $false }
|
|
$ip = [System.IPAddress]::Parse($IpAddress)
|
|
foreach ($net in $script:Config.Smtp.AllowUnauthenticatedNetworks) {
|
|
if (Test-IpInCidr $ip $net) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Add-RelayMessageHeaders {
|
|
param(
|
|
[Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][System.Collections.Generic.List[string]]$Lines,
|
|
[Parameter(Mandatory)][string]$QueueId,
|
|
[Parameter(Mandatory)][string]$RemoteAddress,
|
|
[string]$MailFrom = "",
|
|
[string[]]$Recipients = @()
|
|
)
|
|
|
|
$result = New-Object System.Collections.Generic.List[string]
|
|
$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
|
|
$hasDate = $false
|
|
$hasFrom = $false
|
|
$hasTo = $false
|
|
|
|
for ($i = 0; $i -lt $headerEnd; $i++) {
|
|
if ($Lines[$i] -match '^(?i)Message-ID\s*:') { $hasMessageId = $true }
|
|
if ($Lines[$i] -match '^(?i)Date\s*:') { $hasDate = $true }
|
|
if ($Lines[$i] -match '^(?i)From\s*:') { $hasFrom = $true }
|
|
if ($Lines[$i] -match '^(?i)(To|Cc|Bcc)\s*:') { $hasTo = $true }
|
|
}
|
|
|
|
$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"
|
|
|
|
$result.Add($received)
|
|
if (-not $hasDate) { $result.Add("Date: $date") }
|
|
|
|
if (-not $hasMessageId) {
|
|
$safeHost = ($hostname -replace '[^A-Za-z0-9._-]', '-')
|
|
$utcNowStr = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss')
|
|
$messageId = "<$QueueId.$utcNowStr@$safeHost>"
|
|
$result.Add("Message-ID: $messageId")
|
|
}
|
|
|
|
if (-not $hasFrom -and -not [string]::IsNullOrWhiteSpace($MailFrom)) {
|
|
$result.Add("From: <$MailFrom>")
|
|
}
|
|
|
|
if (-not $hasTo -and @($Recipients).Count -gt 0) {
|
|
$toFormatted = ($Recipients | ForEach-Object { "<$_>" }) -join ", "
|
|
$result.Add("To: $toFormatted")
|
|
}
|
|
|
|
for ($i = 0; $i -lt $Lines.Count; $i++) { $result.Add($Lines[$i]) }
|
|
if ($separatorIndex -lt 0) { $result.Add("") }
|
|
|
|
return $result
|
|
}
|
|
|
|
function Save-SmtpMessage {
|
|
param(
|
|
[Parameter(Mandatory)][System.Collections.Generic.List[string]]$Lines,
|
|
[Parameter(Mandatory)][string]$RemoteAddress,
|
|
[Parameter(Mandatory)][string]$AuthenticatedUser,
|
|
[Parameter(Mandatory)][string]$MailFrom,
|
|
[Parameter(Mandatory)][string[]]$Recipients
|
|
)
|
|
|
|
$dirs = Get-QueueDirectories
|
|
$queueId = New-QueueId
|
|
|
|
$processedLines = Add-RelayMessageHeaders -Lines $Lines -QueueId $queueId -RemoteAddress $RemoteAddress -MailFrom $MailFrom -Recipients $Recipients
|
|
|
|
$meta = [ordered]@{
|
|
QueueId = $queueId
|
|
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
|
|
RemoteAddress = $RemoteAddress
|
|
AuthenticatedUser = $AuthenticatedUser
|
|
EnvelopeFrom = $MailFrom
|
|
EnvelopeRecipients = $Recipients
|
|
RetryCount = 0
|
|
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
|
}
|
|
|
|
$incomingEml = Join-Path $dirs.Incoming "$queueId.eml"
|
|
$incomingMeta = "$incomingEml.json"
|
|
$pendingEml = Join-Path $dirs.Pending "$queueId.eml"
|
|
$pendingMeta = "$pendingEml.json"
|
|
|
|
try {
|
|
$rawBytes = [System.Text.Encoding]::GetEncoding("ISO-8859-1").GetBytes(($processedLines -join "`r`n") + "`r`n")
|
|
[IO.File]::WriteAllBytes($incomingEml, $rawBytes)
|
|
[IO.File]::WriteAllText($incomingMeta, ($meta | ConvertTo-Json -Depth 5), [System.Text.Encoding]::UTF8)
|
|
|
|
Move-Item -LiteralPath $incomingEml -Destination $pendingEml -Force
|
|
Move-Item -LiteralPath $incomingMeta -Destination $pendingMeta -Force
|
|
|
|
Write-Log "[$queueId] Nachricht erfolgreich in Queue aufgenommen (Absender: $MailFrom, Empfaenger: $(@($Recipients) -join ', '))."
|
|
return $true
|
|
} catch {
|
|
Write-Log "[$queueId] Fehler beim Speichern der Nachricht: $($_.Exception.Message)" "ERROR"
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Get-RelayCertificateStatus {
|
|
$thumb = [string]$script:Config.Graph.CertificateThumbprint
|
|
if ([string]::IsNullOrWhiteSpace($thumb)) { throw "Kein CertificateThumbprint in config.json hinterlegt." }
|
|
|
|
$cert = Get-Item -LiteralPath "Cert:\LocalMachine\My\$thumb" -ErrorAction SilentlyContinue
|
|
if (-not $cert) { throw "Zertifikat mit Thumbprint $thumb wurde im Cert:\LocalMachine\My nicht gefunden." }
|
|
|
|
$now = Get-Date
|
|
$expired = $now -gt $cert.NotAfter
|
|
$daysRemaining = [int]($cert.NotAfter - $now).TotalDays
|
|
if ($daysRemaining -lt 0) { $daysRemaining = 0 }
|
|
|
|
return [pscustomobject]@{
|
|
Thumbprint = $thumb
|
|
NotAfter = $cert.NotAfter
|
|
DaysRemaining = $daysRemaining
|
|
Expired = $expired
|
|
}
|
|
}
|
|
|
|
function Send-CertificateAlertMail {
|
|
param([Parameter(Mandatory)][int]$DaysRemaining, [Parameter(Mandatory)][string]$Thumbprint)
|
|
|
|
$alertMailbox = $null
|
|
if ($script:Config.Graph.PSObject.Properties.Name -contains "AlertMailbox") { $alertMailbox = [string]$script:Config.Graph.AlertMailbox }
|
|
if ([string]::IsNullOrWhiteSpace($alertMailbox)) { return }
|
|
|
|
$dirs = Get-QueueDirectories
|
|
$queueId = New-QueueId
|
|
$hostname = if ([string]::IsNullOrWhiteSpace($script:Config.Smtp.Hostname)) { $env:COMPUTERNAME } else { [string]$script:Config.Smtp.Hostname }
|
|
$safeHost = ($hostname -replace '[^A-Za-z0-9._-]', '-')
|
|
$dateStr = [DateTimeOffset]::Now.ToString("ddd, dd MMM yyyy HH:mm:ss zzz", [Globalization.CultureInfo]::InvariantCulture)
|
|
$msgId = "<$queueId.$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))@$safeHost>"
|
|
|
|
$subject = "WICHTIG: SMTPGraphRelay Zertifikat laeuft ab ($hostname)"
|
|
$body = "Hallo,`r`n`r`ndas Microsoft Graph Zertifikat ($Thumbprint) auf dem Server $hostname laeuft in $DaysRemaining Tagen ab.`r`n`r`nBitte das Setup-Skript (Setup-SMTPGraphRelay.ps1) starten und Modus [5] (Zertifikat erneuern) ausfuehren, damit der Mailversand nicht unterbrochen wird.`r`n`r`nViele Gruesse,`r`nSMTPGraphRelay"
|
|
|
|
$lines = New-Object System.Collections.Generic.List[string]
|
|
$lines.Add("Date: $dateStr")
|
|
$lines.Add("Message-ID: $msgId")
|
|
$lines.Add("From: <$($script:Config.Graph.SenderMailbox)>")
|
|
$lines.Add("To: <$alertMailbox>")
|
|
$lines.Add("Subject: $subject")
|
|
$lines.Add("MIME-Version: 1.0")
|
|
$lines.Add("Content-Type: text/plain; charset=utf-8")
|
|
$lines.Add("")
|
|
$lines.Add($body)
|
|
|
|
$meta = [ordered]@{
|
|
QueueId = $queueId
|
|
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
|
|
RemoteAddress = "127.0.0.1"
|
|
AuthenticatedUser = "SystemAlert"
|
|
EnvelopeFrom = $script:Config.Graph.SenderMailbox
|
|
EnvelopeRecipients = @($alertMailbox)
|
|
RetryCount = 0
|
|
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
|
}
|
|
|
|
$pendingEml = Join-Path $dirs.Pending "$queueId.eml"
|
|
$pendingMeta = "$pendingEml.json"
|
|
|
|
try {
|
|
$raw = ($lines -join "`r`n") + "`r`n"
|
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
[IO.File]::WriteAllText($pendingEml, $raw, $utf8NoBom)
|
|
[IO.File]::WriteAllText($pendingMeta, ($meta | ConvertTo-Json -Depth 5), $utf8NoBom)
|
|
Write-Log "Zertifikats-Warnmail an $alertMailbox in die Queue gelegt (QueueID: $queueId)." "INFO"
|
|
} catch { Write-Log "Fehler beim Erstellen der Warnmail: $($_.Exception.Message)" "ERROR" }
|
|
}
|
|
|
|
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"
|
|
} elseif ($status.DaysRemaining -le $criticalDays) {
|
|
Write-Log ("KRITISCH: Graph-Zertifikat laeuft 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 laeuft 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 gueltig bis {0} ({1} Tage verbleibend)." -f $status.NotAfter.ToString("yyyy-MM-dd HH:mm:ss"), $status.DaysRemaining)
|
|
}
|
|
|
|
if ($status.DaysRemaining -le $warningDays -and -not $status.Expired) {
|
|
$logDir = Resolve-PathFromConfig $script:Config.Paths.Logs
|
|
$alertStateFile = Join-Path $logDir "last_cert_alert.txt"
|
|
$lastAlert = [DateTime]::MinValue
|
|
|
|
if (Test-Path -LiteralPath $alertStateFile) {
|
|
try {
|
|
$rawDate = [IO.File]::ReadAllText($alertStateFile).Trim()
|
|
$lastAlert = [DateTime]::Parse($rawDate)
|
|
} catch {}
|
|
}
|
|
|
|
if ((Get-Date) -gt $lastAlert.AddHours(24)) {
|
|
Send-CertificateAlertMail -DaysRemaining $status.DaysRemaining -Thumbprint $status.Thumbprint
|
|
try { [IO.File]::WriteAllText($alertStateFile, (Get-Date).ToString("o")) } catch {}
|
|
}
|
|
}
|
|
|
|
return $status
|
|
} catch { Write-Log ("Zertifikatspruefung fehlgeschlagen: {0}" -f $_.Exception.Message) "ERROR"; return $null }
|
|
}
|
|
|
|
function Get-GraphConnection {
|
|
Import-Module Microsoft.Graph.Authentication -Force -ErrorAction Stop
|
|
Import-Module Microsoft.Graph.Users.Actions -Force -ErrorAction Stop
|
|
|
|
$ctx = Get-MgContext -ErrorAction SilentlyContinue
|
|
if (-not $ctx -or $ctx.AuthType -ne "AppOnly" -or $ctx.ClientId -ne $script:Config.Graph.ClientId) {
|
|
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
|
|
$tenantId = [string]$script:Config.Graph.TenantId
|
|
$clientId = [string]$script:Config.Graph.ClientId
|
|
$thumb = [string]$script:Config.Graph.CertificateThumbprint
|
|
|
|
$cert = Get-Item -LiteralPath "Cert:\LocalMachine\My\$thumb"
|
|
Connect-MgGraph -TenantId $tenantId -ClientId $clientId -Certificate $cert -NoWelcome | Out-Null
|
|
Write-Log "Microsoft Graph App-only Verbindung erfolgreich aufgebaut (Client: $clientId)."
|
|
}
|
|
}
|
|
|
|
function Set-MimeSender {
|
|
param([byte[]]$MimeBytes, [string]$Sender)
|
|
$text = [System.Text.Encoding]::GetEncoding("ISO-8859-1")->GetString($MimeBytes)
|
|
$lines = $text -split "`r`n"
|
|
$replaced = $false
|
|
|
|
for ($i = 0; $i -lt $lines.Count; $i++) {
|
|
if ($lines[$i] -eq "") { break }
|
|
if ($lines[$i] -match '^(?i)From\s*:\s*(.*)$') {
|
|
$lines[$i] = "From: <$Sender>"
|
|
$replaced = $true
|
|
break
|
|
}
|
|
}
|
|
|
|
if (-not $replaced) {
|
|
$lines = @("From: <$Sender>") + $lines
|
|
}
|
|
|
|
return [System.Text.Encoding]::GetEncoding("ISO-8859-1")->GetBytes(($lines -join "`r`n"))
|
|
}
|
|
|
|
function Send-QueuedMail {
|
|
param([string]$FilePath, [pscustomobject]$meta)
|
|
|
|
$queueId = if ($meta -and $meta.QueueId) { $meta.QueueId } else { [IO.Path]::GetFileNameWithoutExtension($FilePath) }
|
|
|
|
if (-not (Test-Path -LiteralPath $FilePath)) { return $true }
|
|
|
|
$bytes = [IO.File]::ReadAllBytes($FilePath)
|
|
$effectiveSender = [string]$script:Config.Graph.SenderMailbox
|
|
|
|
if (-not $script:Config.Graph.ForceSender) {
|
|
if ($meta -and $meta.PSObject.Properties.Name -contains "EnvelopeFrom") {
|
|
$envFrom = [string]$meta.EnvelopeFrom
|
|
if (-not [string]::IsNullOrWhiteSpace($envFrom) -and $envFrom -match '^[^@\s]+@[^@\s]+\.[^@\s]+$') {
|
|
$effectiveSender = $envFrom
|
|
}
|
|
}
|
|
}
|
|
$bytes = Set-MimeSender -MimeBytes $bytes -Sender $effectiveSender
|
|
|
|
try {
|
|
Get-GraphConnection
|
|
$senderEnc = [Uri]::EscapeDataString($effectiveSender)
|
|
$uri = "https://graph.microsoft.com/v1.0/users/$senderEnc/sendMail"
|
|
$base64 = [Convert]::ToBase64String($bytes)
|
|
|
|
Invoke-MgGraphRequest -Method POST -Uri $uri -Body $base64 -ContentType "text/plain" -OutputType PSObject | Out-Null
|
|
Write-Log "[$queueId] Mail erfolgreich ueber Graph versendet (Postfach: $effectiveSender)."
|
|
return $true
|
|
} catch {
|
|
$ex = $_.Exception
|
|
$statusCode = 0
|
|
$retryAfterSec = 0
|
|
$errMessage = $ex.Message
|
|
|
|
if ($ex -is [Microsoft.Graph.PowerShell.Authentication.ApiException]) {
|
|
$statusCode = [int]$ex.Response.StatusCode
|
|
}
|
|
|
|
Write-Log "[$queueId] Graph-Fehler (HTTP $statusCode): $errMessage" "ERROR"
|
|
|
|
$decision = Get-RetryDecision -Exception $ex -Meta $meta
|
|
Move-ToFailedOrRetry -FilePath $FilePath -Meta $meta -Decision $decision -LastError $errMessage
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Get-RetryDecision {
|
|
param($Exception, [pscustomobject]$meta)
|
|
$retryCount = if ($meta -and $meta.RetryCount -ne $null) { [int]$meta.RetryCount } else { 0 }
|
|
|
|
$statusCode = 0
|
|
if ($Exception -is [Microsoft.Graph.PowerShell.Authentication.ApiException]) {
|
|
$statusCode = [int]$Exception.Response.StatusCode
|
|
}
|
|
|
|
if ($statusCode -eq 429) {
|
|
return [pscustomobject]@{ Action = "Retry"; DelayMinutes = 5; StatusCode = 429 }
|
|
}
|
|
if ($statusCode -ge 500 -and $statusCode -lt 600) {
|
|
$delay = Get-ExponentialBackoffMinutes -RetryCount $retryCount
|
|
return [pscustomobject]@{ Action = "Retry"; DelayMinutes = $delay; StatusCode = $statusCode }
|
|
}
|
|
if ($statusCode -eq 400 -or $statusCode -eq 403 -or $statusCode -eq 404) {
|
|
return [pscustomobject]@{ Action = "Failed"; StatusCode = $statusCode }
|
|
}
|
|
|
|
$delay = Get-ExponentialBackoffMinutes -RetryCount $retryCount
|
|
return [pscustomobject]@{ Action = "Retry"; DelayMinutes = $delay; StatusCode = $statusCode }
|
|
}
|
|
|
|
function Get-ExponentialBackoffMinutes {
|
|
param([int]$RetryCount)
|
|
if ($script:Config.Queue.RetryMinutes -and $RetryCount -lt $script:Config.Queue.RetryMinutes.Count) {
|
|
return [int]$script:Config.Queue.RetryMinutes[$RetryCount]
|
|
}
|
|
return 60
|
|
}
|
|
|
|
function Move-ToFailedOrRetry {
|
|
param([string]$FilePath, [pscustomobject]$meta, [pscustomobject]$Decision, [string]$LastError)
|
|
|
|
$dirs = Get-QueueDirectories
|
|
$queueId = if ($meta) { $meta.QueueId } else { [IO.Path]::GetFileNameWithoutExtension($FilePath) }
|
|
$metaPath = "$FilePath.json"
|
|
|
|
$maxRetries = if ($script:Config.Queue.MaxRetries) { [int]$script:Config.Queue.MaxRetries } else { 8 }
|
|
$currentRetry = if ($meta -and $meta.RetryCount -ne $null) { [int]$meta.RetryCount } else { 0 }
|
|
|
|
if ($Decision.Action -eq "Failed" -or $currentRetry -ge $maxRetries) {
|
|
$failedDir = Resolve-PathFromConfig $script:Config.Paths.Failed
|
|
if (-not (Test-Path -LiteralPath $failedDir)) { New-Item -ItemType Directory -Path $failedDir -Force | Out-Null }
|
|
|
|
$destEml = Join-Path $failedDir (Split-Path $FilePath -Leaf)
|
|
$destMeta = "$destEml.json"
|
|
|
|
if (Test-Path -LiteralPath $FilePath) { Move-Item -LiteralPath $FilePath -Destination $destEml -Force }
|
|
if (Test-Path -LiteralPath $metaPath) {
|
|
$meta.LastStatusCode = $Decision.StatusCode
|
|
$meta.LastError = $LastError
|
|
$meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $destMeta -Encoding UTF8
|
|
Remove-Item -LiteralPath $metaPath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Write-Log "[$queueId] Mail endgueltig fehlgeschlagen (nach $currentRetry Versuchen) -> Ordner 'failed'." "ERROR"
|
|
} else {
|
|
$nextRetry = $currentRetry + 1
|
|
$nextTime = (Get-Date).AddMinutes($Decision.DelayMinutes).ToString("o")
|
|
|
|
if ($meta) {
|
|
$meta.RetryCount = $nextRetry
|
|
$meta.NextAttemptUtc = $nextTime
|
|
$meta.LastStatusCode = $Decision.StatusCode
|
|
$meta.LastError = $LastError
|
|
$meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8
|
|
}
|
|
Write-Log "[$queueId] Mail wird in $($Decision.DelayMinutes) Min. erneut versucht (Versuch $nextRetry)." "WARN"
|
|
}
|
|
}
|
|
|
|
function Start-SmtpListener {
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
|
$ip = [System.IPAddress]::Parse($script:Config.Smtp.ListenAddress)
|
|
$port = [int]$script:Config.Smtp.Port
|
|
|
|
$listener = New-Object System.Net.Sockets.TcpListener($ip, $port)
|
|
$listener.Start()
|
|
Write-Log "SMTP Listener gestartet auf $($script:Config.Smtp.ListenAddress):$port"
|
|
|
|
$dirs = Get-QueueDirectories
|
|
|
|
while ($true) {
|
|
if (Test-Path -LiteralPath (Join-Path $PSScriptRoot "shutdown.request")) {
|
|
Write-Log "Shutdown-Signal erhalten. Beende Listener..."
|
|
break
|
|
}
|
|
|
|
if ($listener.Pending()) {
|
|
$client = $listener.AcceptTcpClient()
|
|
$remoteIp = ($client.Client.RemoteEndPoint -as [System.Net.IPEndPoint]).Address.IPAddressToString
|
|
|
|
if (-not (Test-ClientAllowed $remoteIp)) {
|
|
Write-Log "Verbindung von nicht erlaubteter IP $remoteIp abgelehnt." "WARN"
|
|
$client.Close()
|
|
continue
|
|
}
|
|
|
|
[void][Threading.ThreadPool]::QueueUserWorkItem({
|
|
param($TcpClient, $ClientIp)
|
|
try {
|
|
$stream = $TcpClient.GetStream()
|
|
$reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::ASCII)
|
|
$writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII)
|
|
$writer.AutoFlush = $true
|
|
|
|
$writer.WriteLine("220 $($script:Config.Smtp.Hostname) ESMTP SMTPGraphRelay Ready")
|
|
|
|
$mailFrom = ""
|
|
$recipients = New-Object System.Collections.Generic.List[string]
|
|
$dataLines = New-Object System.Collections.Generic.List[string]
|
|
$receivingData = $false
|
|
$authenticatedUser = ""
|
|
$authStep = 0
|
|
|
|
while ($TcpClient.Connected -and ($line = $reader.ReadLine()) -ne $null) {
|
|
$trimmed = $line.TrimEnd("`r", "`n")
|
|
|
|
if ($receivingData) {
|
|
if ($trimmed -eq ".") {
|
|
$receivingData = $false
|
|
if (Save-SmtpMessage -Lines $dataLines -RemoteAddress $ClientIp -AuthenticatedUser $authenticatedUser -MailFrom $mailFrom -Recipients $recipients) {
|
|
$writer.WriteLine("250 2.0.0 OK: Message accepted")
|
|
} else {
|
|
$writer.WriteLine("451 4.3.0 Internal server error")
|
|
}
|
|
$mailFrom = ""
|
|
$recipients.Clear()
|
|
$dataLines.Clear()
|
|
} else {
|
|
if ($trimmed.StartsWith("..")) { $trimmed = $trimmed.Substring(1) }
|
|
$dataLines.Add($trimmed)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if ($trimmed -match '^(?i)EHLO\s+(.*)$' -or $trimmed -match '^(?i)HELO\s+(.*)$') {
|
|
$writer.WriteLine("250-$($script:Config.Smtp.Hostname) Hello")
|
|
$writer.WriteLine("250-SIZE 26214400")
|
|
$writer.WriteLine("250-8BITMIME")
|
|
if ($script:Config.Smtp.RequireAuth) {
|
|
$writer.WriteLine("250-AUTH LOGIN PLAIN")
|
|
}
|
|
$writer.WriteLine("250 HELP")
|
|
}
|
|
elseif ($trimmed -match '^(?i)MAIL FROM:\s*(?:<([^>]*)>|([^\s>]+))(?:\s+.*)?$') {
|
|
$mailFrom = if ($Matches[1]) { $Matches[1] } else { $Matches[2] }
|
|
$writer.WriteLine("250 2.1.0 Sender OK")
|
|
}
|
|
elseif ($trimmed -match '^(?i)RCPT TO:\s*(?:<([^>]+)>|([^\s>]+))(?:\s+.*)?$') {
|
|
$rcpt = if ($Matches[1]) { $Matches[1] } else { $Matches[2] }
|
|
[void]$recipients.Add($rcpt)
|
|
$writer.WriteLine("250 2.1.5 Recipient OK")
|
|
}
|
|
elseif ($trimmed -match '^(?i)DATA$') {
|
|
if ($recipients.Count -eq 0) {
|
|
$writer.WriteLine("503 5.5.1 Error: need RCPT command")
|
|
continue
|
|
}
|
|
$receivingData = $true
|
|
$dataLines.Clear()
|
|
$writer.WriteLine("354 Start mail input; end with <CR><LF>.<CR><LF>")
|
|
}
|
|
elseif ($trimmed -match '^(?i)QUIT$') {
|
|
$writer.WriteLine("221 2.0.0 Bye")
|
|
break
|
|
}
|
|
else {
|
|
$writer.WriteLine("500 5.5.2 Command unrecognized")
|
|
}
|
|
}
|
|
} catch {
|
|
Write-Log "Fehler in Client-Session ($ClientIp): $($_.Exception.Message)" "ERROR"
|
|
} finally {
|
|
$TcpClient.Close()
|
|
}
|
|
}, $client)
|
|
}
|
|
|
|
# Queue verarbeiten
|
|
$pendingFiles = Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File -ErrorAction SilentlyContinue
|
|
foreach ($file in $pendingFiles) {
|
|
$metaPath = "$($file.FullName).json"
|
|
$meta = $null
|
|
if (Test-Path -LiteralPath $metaPath) {
|
|
try {
|
|
$meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
if ($meta.PSObject.Properties.Name -contains "NextAttemptUtc" -and $meta.NextAttemptUtc) {
|
|
$nextTry = [DateTime]::Parse($meta.NextAttemptUtc)
|
|
if ((Get-Date) -lt $nextTry) { continue }
|
|
}
|
|
} catch {}
|
|
}
|
|
Send-QueuedMail -FilePath $file.FullName -meta $meta
|
|
}
|
|
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
|
|
$listener.Stop()
|
|
}
|
|
|
|
# Hauptschleife
|
|
try {
|
|
if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Konfigurationsdatei nicht gefunden: $ConfigPath" }
|
|
$script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
|
|
Write-Log "SMTPGraphRelay wird gestartet..."
|
|
Test-RelayCertificateExpiry -ForceLog
|
|
|
|
Start-SmtpListener
|
|
} catch {
|
|
Write-Log "Kritischer Fehler im Relay: $($_.Exception.Message)" "ERROR"
|
|
exit 1
|
|
} |