827 lines
29 KiB
PowerShell
827 lines
29 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.2: robuste Queue, statuscodeabhängiger Graph-Retry, EHLO/HELO, MAIL FROM, RCPT TO, DATA, RSET, NOOP, QUIT
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$ConfigPath = "$PSScriptRoot\config.json"
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
|
|
function Write-Log {
|
|
param(
|
|
[Parameter(Mandatory)][string]$Message,
|
|
[ValidateSet("INFO","WARN","ERROR","DEBUG")][string]$Level = "INFO"
|
|
)
|
|
|
|
$ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff"
|
|
$line = "[$ts] [$Level] $Message"
|
|
Write-Host $line
|
|
|
|
try {
|
|
if ($script:Config -and $script:Config.Paths.Logs) {
|
|
$logDir = $script:Config.Paths.Logs
|
|
if (-not [IO.Path]::IsPathRooted($logDir)) { $logDir = Join-Path $PSScriptRoot $logDir }
|
|
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
|
Add-Content -LiteralPath (Join-Path $logDir "SMTPGraphRelay.log") -Value $line -Encoding UTF8
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
function Resolve-PathFromConfig {
|
|
param([Parameter(Mandatory)][string]$Path)
|
|
if ([IO.Path]::IsPathRooted($Path)) { return $Path }
|
|
return (Join-Path $PSScriptRoot $Path)
|
|
}
|
|
|
|
function Test-IPv4InCidr {
|
|
param(
|
|
[Parameter(Mandatory)][System.Net.IPAddress]$Address,
|
|
[Parameter(Mandatory)][string]$Cidr
|
|
)
|
|
|
|
if ($Address.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) {
|
|
return $false
|
|
}
|
|
|
|
if ($Cidr -notmatch '^(.+)/(\d{1,2})$') { return $false }
|
|
|
|
try {
|
|
$network = [System.Net.IPAddress]::Parse($matches[1])
|
|
} catch {
|
|
return $false
|
|
}
|
|
|
|
if ($network.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) {
|
|
return $false
|
|
}
|
|
|
|
$prefix = [int]$matches[2]
|
|
if ($prefix -lt 0 -or $prefix -gt 32) { return $false }
|
|
|
|
$ipBytes = $Address.GetAddressBytes()
|
|
$netBytes = $network.GetAddressBytes()
|
|
|
|
for ($i = 0; $i -lt 4; $i++) {
|
|
$remaining = $prefix - ($i * 8)
|
|
if ($remaining -le 0) { break }
|
|
|
|
$bits = [Math]::Min(8, $remaining)
|
|
[byte]$mask = (0xFF -shl (8 - $bits)) -band 0xFF
|
|
|
|
if (($ipBytes[$i] -band $mask) -ne ($netBytes[$i] -band $mask)) {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
return $true
|
|
}
|
|
|
|
function Test-ClientAllowed {
|
|
param([Parameter(Mandatory)][System.Net.IPAddress]$Address)
|
|
|
|
foreach ($entry in @($script:Config.Smtp.AllowedNetworks)) {
|
|
if ($entry -eq "*") { return $true }
|
|
try {
|
|
if ($entry -match '/') {
|
|
if (Test-IPv4InCidr -Address $Address -Cidr $entry) { return $true }
|
|
} elseif ([System.Net.IPAddress]::Parse($entry).Equals($Address)) {
|
|
return $true
|
|
}
|
|
} catch {}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Get-GraphConnection {
|
|
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
|
|
throw "Microsoft.Graph.Authentication ist nicht installiert."
|
|
}
|
|
|
|
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop
|
|
|
|
$cert = Get-Item -LiteralPath ("Cert:\LocalMachine\My\{0}" -f $script:Config.Graph.CertificateThumbprint) -ErrorAction Stop
|
|
|
|
$ctx = Get-MgContext
|
|
$needsConnect = $true
|
|
if ($ctx) {
|
|
if ($ctx.ClientId -eq $script:Config.Graph.ClientId -and $ctx.TenantId -eq $script:Config.Graph.TenantId -and $ctx.AuthType -eq "AppOnly") {
|
|
$needsConnect = $false
|
|
}
|
|
}
|
|
|
|
if ($needsConnect) {
|
|
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
|
|
Connect-MgGraph `
|
|
-TenantId $script:Config.Graph.TenantId `
|
|
-ClientId $script:Config.Graph.ClientId `
|
|
-Certificate $cert `
|
|
-NoWelcome | Out-Null
|
|
}
|
|
}
|
|
|
|
function Set-MimeSender {
|
|
param(
|
|
[Parameter(Mandatory)][byte[]]$MimeBytes,
|
|
[Parameter(Mandatory)][string]$Sender
|
|
)
|
|
|
|
# Headerbereich als Latin1 lesen, damit Bytes 1:1 erhalten bleiben.
|
|
$latin1 = [System.Text.Encoding]::GetEncoding(28591)
|
|
$text = $latin1.GetString($MimeBytes)
|
|
$separator = "`r`n`r`n"
|
|
$idx = $text.IndexOf($separator)
|
|
if ($idx -lt 0) {
|
|
$separator = "`n`n"
|
|
$idx = $text.IndexOf($separator)
|
|
}
|
|
if ($idx -lt 0) { return $MimeBytes }
|
|
|
|
$headers = $text.Substring(0, $idx)
|
|
$body = $text.Substring($idx + $separator.Length)
|
|
|
|
if ($headers -match '(?im)^From:.*(?:\r?\n[ \t].*)*') {
|
|
$headers = [regex]::Replace(
|
|
$headers,
|
|
'(?im)^From:.*(?:\r?\n[ \t].*)*',
|
|
"From: <$Sender>",
|
|
1
|
|
)
|
|
} else {
|
|
$headers = "From: <$Sender>`r`n" + $headers
|
|
}
|
|
|
|
return $latin1.GetBytes($headers + "`r`n`r`n" + $body)
|
|
}
|
|
|
|
function Get-QueueDirectories {
|
|
$queueRoot = Resolve-PathFromConfig $script:Config.Paths.Queue
|
|
$failedDir = Resolve-PathFromConfig $script:Config.Paths.Failed
|
|
|
|
return [pscustomobject]@{
|
|
Root = $queueRoot
|
|
Incoming = Join-Path $queueRoot "incoming"
|
|
Pending = Join-Path $queueRoot "pending"
|
|
Processing = Join-Path $queueRoot "processing"
|
|
Failed = $failedDir
|
|
}
|
|
}
|
|
|
|
function Initialize-QueueDirectories {
|
|
$dirs = Get-QueueDirectories
|
|
|
|
foreach ($dir in @(
|
|
$dirs.Root,
|
|
$dirs.Incoming,
|
|
$dirs.Pending,
|
|
$dirs.Processing,
|
|
$dirs.Failed
|
|
)) {
|
|
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
|
}
|
|
|
|
# Alte V1-Mails direkt aus queue\ nach pending migrieren.
|
|
foreach ($file in Get-ChildItem -LiteralPath $dirs.Root -Filter "*.eml" -File -ErrorAction SilentlyContinue) {
|
|
$target = Join-Path $dirs.Pending $file.Name
|
|
if (-not (Test-Path -LiteralPath $target)) {
|
|
Move-Item -LiteralPath $file.FullName -Destination $target -Force
|
|
}
|
|
|
|
$oldMeta = "$($file.FullName).json"
|
|
if (Test-Path -LiteralPath $oldMeta) {
|
|
$targetMeta = "$target.json"
|
|
if (-not (Test-Path -LiteralPath $targetMeta)) {
|
|
Move-Item -LiteralPath $oldMeta -Destination $targetMeta -Force
|
|
}
|
|
}
|
|
|
|
Write-Log "Alte Queue-Mail nach pending migriert: $($file.Name)"
|
|
}
|
|
|
|
# Nach Absturz/Neustart können Dateien in processing liegen.
|
|
# Sie werden wieder nach pending gestellt und später erneut versucht.
|
|
foreach ($file in Get-ChildItem -LiteralPath $dirs.Processing -Filter "*.eml" -File -ErrorAction SilentlyContinue) {
|
|
$pendingPath = Join-Path $dirs.Pending $file.Name
|
|
$processingMeta = "$($file.FullName).json"
|
|
$pendingMeta = "$pendingPath.json"
|
|
|
|
if (Test-Path -LiteralPath $processingMeta) {
|
|
Move-Item -LiteralPath $processingMeta -Destination $pendingMeta -Force
|
|
}
|
|
|
|
Move-Item -LiteralPath $file.FullName -Destination $pendingPath -Force
|
|
Write-Log "Processing-Mail nach Neustart zurück nach pending gestellt: $($file.Name)" "WARN"
|
|
}
|
|
}
|
|
|
|
function Get-GraphFailureInfo {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
$ErrorRecord
|
|
)
|
|
|
|
$statusCode = $null
|
|
$retryAfterSeconds = $null
|
|
$message = $ErrorRecord.Exception.Message
|
|
|
|
try {
|
|
$response = $ErrorRecord.Exception.Response
|
|
|
|
if ($response) {
|
|
try {
|
|
if ($null -ne $response.StatusCode) {
|
|
$statusCode = [int]$response.StatusCode
|
|
}
|
|
} catch {}
|
|
|
|
try {
|
|
$headers = $response.Headers
|
|
|
|
if ($headers) {
|
|
# HttpResponseMessage / HttpResponseHeaders
|
|
try {
|
|
$values = $null
|
|
if ($headers.TryGetValues("Retry-After", [ref]$values)) {
|
|
$raw = @($values)[0]
|
|
if ($raw -match '^\d+$') {
|
|
$retryAfterSeconds = [int]$raw
|
|
} else {
|
|
$retryDate = [DateTimeOffset]::Parse($raw)
|
|
$seconds = [Math]::Ceiling(($retryDate - [DateTimeOffset]::UtcNow).TotalSeconds)
|
|
if ($seconds -gt 0) {
|
|
$retryAfterSeconds = [int]$seconds
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
# WebResponse-artige Header
|
|
if ($null -eq $retryAfterSeconds) {
|
|
try {
|
|
$raw = $headers["Retry-After"]
|
|
if ($raw) {
|
|
if ($raw -match '^\d+$') {
|
|
$retryAfterSeconds = [int]$raw
|
|
} else {
|
|
$retryDate = [DateTimeOffset]::Parse($raw)
|
|
$seconds = [Math]::Ceiling(($retryDate - [DateTimeOffset]::UtcNow).TotalSeconds)
|
|
if ($seconds -gt 0) {
|
|
$retryAfterSeconds = [int]$seconds
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
} catch {}
|
|
|
|
# Fallback: Statuscode aus Text extrahieren, falls das Graph-Modul ihn nur dort liefert.
|
|
if ($null -eq $statusCode) {
|
|
$combined = "$message $($ErrorRecord | Out-String)"
|
|
if ($combined -match '(?<!\d)(400|401|403|404|408|409|410|412|413|415|422|423|429|500|501|502|503|504)(?!\d)') {
|
|
$statusCode = [int]$matches[1]
|
|
}
|
|
}
|
|
|
|
return [pscustomobject]@{
|
|
StatusCode = $statusCode
|
|
RetryAfterSeconds = $retryAfterSeconds
|
|
Message = $message
|
|
}
|
|
}
|
|
|
|
function Get-RetryDecision {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
$FailureInfo,
|
|
|
|
[Parameter(Mandatory)]
|
|
[int]$RetryCount
|
|
)
|
|
|
|
$statusCode = $FailureInfo.StatusCode
|
|
|
|
# Graph Throttling: Retry-After bevorzugen.
|
|
if ($statusCode -eq 429) {
|
|
if ($FailureInfo.RetryAfterSeconds -and $FailureInfo.RetryAfterSeconds -gt 0) {
|
|
return [pscustomobject]@{
|
|
Retry = $true
|
|
DelaySeconds = [int]$FailureInfo.RetryAfterSeconds
|
|
Reason = "Graph 429 / Retry-After"
|
|
}
|
|
}
|
|
|
|
# Falls kein Retry-After vorhanden ist: exponentielles Backoff.
|
|
$seconds = [Math]::Min(3600, [Math]::Pow(2, [Math]::Min($RetryCount, 10)) * 5)
|
|
return [pscustomobject]@{
|
|
Retry = $true
|
|
DelaySeconds = [int]$seconds
|
|
Reason = "Graph 429 / exponentielles Backoff"
|
|
}
|
|
}
|
|
|
|
# Serverfehler und Request Timeout sind typischerweise temporär.
|
|
if ($statusCode -eq 408 -or ($statusCode -ge 500 -and $statusCode -le 599)) {
|
|
$delays = @($script:Config.Queue.RetryMinutes)
|
|
$index = [Math]::Min([Math]::Max($RetryCount - 1, 0), $delays.Count - 1)
|
|
return [pscustomobject]@{
|
|
Retry = $true
|
|
DelaySeconds = ([int]$delays[$index] * 60)
|
|
Reason = "temporärer HTTP-Fehler $statusCode"
|
|
}
|
|
}
|
|
|
|
# Typische Auth-/RBAC-/Requestfehler sind ohne Konfigurationsänderung permanent.
|
|
if ($statusCode -in @(400,401,403,404,409,410,412,413,415,422,423)) {
|
|
return [pscustomobject]@{
|
|
Retry = $false
|
|
DelaySeconds = 0
|
|
Reason = "permanenter HTTP-Fehler $statusCode"
|
|
}
|
|
}
|
|
|
|
# Kein Statuscode: meist DNS/TLS/Socket/Timeout. Mit normalem Retry behandeln.
|
|
if ($null -eq $statusCode) {
|
|
$delays = @($script:Config.Queue.RetryMinutes)
|
|
$index = [Math]::Min([Math]::Max($RetryCount - 1, 0), $delays.Count - 1)
|
|
return [pscustomobject]@{
|
|
Retry = $true
|
|
DelaySeconds = ([int]$delays[$index] * 60)
|
|
Reason = "Netzwerk-/Transportfehler"
|
|
}
|
|
}
|
|
|
|
# Unbekannte 4xx lieber nicht endlos wiederholen.
|
|
if ($statusCode -ge 400 -and $statusCode -le 499) {
|
|
return [pscustomobject]@{
|
|
Retry = $false
|
|
DelaySeconds = 0
|
|
Reason = "unbekannter Clientfehler $statusCode"
|
|
}
|
|
}
|
|
|
|
# Defensive Default-Policy.
|
|
$delays = @($script:Config.Queue.RetryMinutes)
|
|
$index = [Math]::Min([Math]::Max($RetryCount - 1, 0), $delays.Count - 1)
|
|
return [pscustomobject]@{
|
|
Retry = $true
|
|
DelaySeconds = ([int]$delays[$index] * 60)
|
|
Reason = "unbekannter temporärer Fehler"
|
|
}
|
|
}
|
|
|
|
function Move-QueueItem {
|
|
param(
|
|
[Parameter(Mandatory)][string]$SourceEml,
|
|
[Parameter(Mandatory)][string]$DestinationDirectory
|
|
)
|
|
|
|
New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null
|
|
|
|
$sourceMeta = "$SourceEml.json"
|
|
$destinationEml = Join-Path $DestinationDirectory (Split-Path $SourceEml -Leaf)
|
|
$destinationMeta = "$destinationEml.json"
|
|
|
|
# Metadaten zuerst bewegen, .eml zuletzt. Dadurch sieht der Worker niemals
|
|
# eine neue .eml-Datei ohne bereits vorhandene Metadaten.
|
|
if (Test-Path -LiteralPath $sourceMeta) {
|
|
Move-Item -LiteralPath $sourceMeta -Destination $destinationMeta -Force
|
|
}
|
|
|
|
Move-Item -LiteralPath $SourceEml -Destination $destinationEml -Force
|
|
|
|
return $destinationEml
|
|
}
|
|
|
|
function Send-QueuedMail {
|
|
param([Parameter(Mandatory)][string]$FilePath)
|
|
|
|
$dirs = Get-QueueDirectories
|
|
$metaPath = "$FilePath.json"
|
|
$meta = $null
|
|
|
|
if (Test-Path -LiteralPath $metaPath) {
|
|
try {
|
|
$meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
} catch {
|
|
Write-Log "Queue-Metadaten unlesbar für $(Split-Path $FilePath -Leaf): $($_.Exception.Message)" "WARN"
|
|
}
|
|
}
|
|
|
|
if (-not $meta) {
|
|
$meta = [pscustomobject]@{
|
|
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
|
|
RetryCount = 0
|
|
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
|
}
|
|
}
|
|
|
|
$bytes = [IO.File]::ReadAllBytes($FilePath)
|
|
|
|
if ($script:Config.Graph.ForceSender) {
|
|
$bytes = Set-MimeSender -MimeBytes $bytes -Sender $script:Config.Graph.SenderMailbox
|
|
}
|
|
|
|
try {
|
|
Get-GraphConnection
|
|
|
|
$sender = [Uri]::EscapeDataString($script:Config.Graph.SenderMailbox)
|
|
$uri = "https://graph.microsoft.com/v1.0/users/$sender/sendMail"
|
|
$base64 = [Convert]::ToBase64String($bytes)
|
|
|
|
Invoke-MgGraphRequest `
|
|
-Method POST `
|
|
-Uri $uri `
|
|
-Body $base64 `
|
|
-ContentType "text/plain" `
|
|
-OutputType PSObject | Out-Null
|
|
|
|
# Graph sendMail liefert bei Annahme 202 Accepted.
|
|
# Invoke-MgGraphRequest wirft bei Fehlern eine Exception; kein Fehler bedeutet hier angenommen.
|
|
Write-Log "Mail von Graph angenommen: $(Split-Path $FilePath -Leaf)"
|
|
|
|
Remove-Item -LiteralPath $FilePath -Force
|
|
if (Test-Path -LiteralPath $metaPath) {
|
|
Remove-Item -LiteralPath $metaPath -Force
|
|
}
|
|
|
|
return $true
|
|
}
|
|
catch {
|
|
$retryCount = 0
|
|
if ($null -ne $meta.RetryCount) {
|
|
$retryCount = [int]$meta.RetryCount
|
|
}
|
|
$retryCount++
|
|
|
|
$failure = Get-GraphFailureInfo -ErrorRecord $_
|
|
$decision = Get-RetryDecision -FailureInfo $failure -RetryCount $retryCount
|
|
$maxRetries = [int]$script:Config.Queue.MaxRetries
|
|
|
|
$statusText = if ($null -ne $failure.StatusCode) {
|
|
"HTTP $($failure.StatusCode)"
|
|
} else {
|
|
"ohne HTTP-Status"
|
|
}
|
|
|
|
if (-not $decision.Retry) {
|
|
Write-Log ("Graph-Versand permanent fehlgeschlagen ({0}, {1}): {2}" -f $statusText, $decision.Reason, $failure.Message) "ERROR"
|
|
|
|
$newMeta = [ordered]@{
|
|
ReceivedUtc = $meta.ReceivedUtc
|
|
RemoteAddress = $meta.RemoteAddress
|
|
EnvelopeFrom = $meta.EnvelopeFrom
|
|
EnvelopeRecipients = @($meta.EnvelopeRecipients)
|
|
RetryCount = $retryCount
|
|
FailedUtc = [DateTime]::UtcNow.ToString("o")
|
|
LastStatusCode = $failure.StatusCode
|
|
LastError = $failure.Message
|
|
FailureReason = $decision.Reason
|
|
}
|
|
$newMeta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8
|
|
|
|
[void](Move-QueueItem -SourceEml $FilePath -DestinationDirectory $dirs.Failed)
|
|
return $false
|
|
}
|
|
|
|
if ($retryCount -ge $maxRetries) {
|
|
Write-Log ("Graph-Versand nach {0} Versuchen endgültig fehlgeschlagen ({1}): {2}" -f $retryCount, $statusText, $failure.Message) "ERROR"
|
|
|
|
$newMeta = [ordered]@{
|
|
ReceivedUtc = $meta.ReceivedUtc
|
|
RemoteAddress = $meta.RemoteAddress
|
|
EnvelopeFrom = $meta.EnvelopeFrom
|
|
EnvelopeRecipients = @($meta.EnvelopeRecipients)
|
|
RetryCount = $retryCount
|
|
FailedUtc = [DateTime]::UtcNow.ToString("o")
|
|
LastStatusCode = $failure.StatusCode
|
|
LastError = $failure.Message
|
|
FailureReason = "MaxRetries erreicht"
|
|
}
|
|
$newMeta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8
|
|
|
|
[void](Move-QueueItem -SourceEml $FilePath -DestinationDirectory $dirs.Failed)
|
|
return $false
|
|
}
|
|
|
|
$next = (Get-Date).AddSeconds([int]$decision.DelaySeconds)
|
|
$newMeta = [ordered]@{
|
|
ReceivedUtc = $meta.ReceivedUtc
|
|
RemoteAddress = $meta.RemoteAddress
|
|
EnvelopeFrom = $meta.EnvelopeFrom
|
|
EnvelopeRecipients = @($meta.EnvelopeRecipients)
|
|
RetryCount = $retryCount
|
|
NextAttemptUtc = $next.ToUniversalTime().ToString("o")
|
|
LastAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
|
LastStatusCode = $failure.StatusCode
|
|
LastError = $failure.Message
|
|
RetryReason = $decision.Reason
|
|
}
|
|
$newMeta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $metaPath -Encoding UTF8
|
|
|
|
$delayText = if ($decision.DelaySeconds -ge 60) {
|
|
"{0:N1} Min." -f ($decision.DelaySeconds / 60)
|
|
} else {
|
|
"$($decision.DelaySeconds) Sek."
|
|
}
|
|
|
|
Write-Log ("Graph-Versand fehlgeschlagen (Versuch {0}/{1}, {2}, {3}). Neuer Versuch in {4}: {5}" -f `
|
|
$retryCount, $maxRetries, $statusText, $decision.Reason, $delayText, $failure.Message) "WARN"
|
|
|
|
[void](Move-QueueItem -SourceEml $FilePath -DestinationDirectory $dirs.Pending)
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Process-Queue {
|
|
$dirs = Get-QueueDirectories
|
|
|
|
foreach ($file in Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File | Sort-Object CreationTimeUtc) {
|
|
$metaPath = "$($file.FullName).json"
|
|
|
|
if (Test-Path -LiteralPath $metaPath) {
|
|
try {
|
|
$meta = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
|
|
if ($meta.NextAttemptUtc) {
|
|
$next = [DateTime]::Parse($meta.NextAttemptUtc).ToUniversalTime()
|
|
if ($next -gt [DateTime]::UtcNow) {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
Write-Log "Queue-Metadaten konnten nicht gelesen werden: $metaPath" "WARN"
|
|
}
|
|
}
|
|
|
|
try {
|
|
$processingPath = Move-QueueItem `
|
|
-SourceEml $file.FullName `
|
|
-DestinationDirectory $dirs.Processing
|
|
|
|
[void](Send-QueuedMail -FilePath $processingPath)
|
|
}
|
|
catch {
|
|
Write-Log "Queue-Verarbeitung für $($file.Name) fehlgeschlagen: $($_.Exception.Message)" "ERROR"
|
|
|
|
# Falls das Verschieben nach processing bereits geklappt hat, zurück nach pending.
|
|
$possibleProcessing = Join-Path $dirs.Processing $file.Name
|
|
if (Test-Path -LiteralPath $possibleProcessing) {
|
|
try {
|
|
[void](Move-QueueItem -SourceEml $possibleProcessing -DestinationDirectory $dirs.Pending)
|
|
} catch {
|
|
Write-Log "Queue-Recovery fehlgeschlagen für $($file.Name): $($_.Exception.Message)" "ERROR"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Save-SmtpMessage {
|
|
param(
|
|
[Parameter(Mandatory)]
|
|
[AllowEmptyCollection()]
|
|
[AllowEmptyString()]
|
|
[System.Collections.Generic.List[string]]$Lines,
|
|
|
|
[Parameter(Mandatory)]
|
|
[string]$MailFrom,
|
|
|
|
[Parameter(Mandatory)]
|
|
[string[]]$Recipients,
|
|
|
|
[Parameter(Mandatory)]
|
|
[string]$RemoteAddress
|
|
)
|
|
|
|
$dirs = Get-QueueDirectories
|
|
$id = "{0}-{1}" -f (Get-Date -Format "yyyyMMdd-HHmmssfff"), ([guid]::NewGuid().ToString("N").Substring(0,8))
|
|
|
|
$incomingEmlTmp = Join-Path $dirs.Incoming "$id.eml.tmp"
|
|
$incomingMetaTmp = Join-Path $dirs.Incoming "$id.eml.json.tmp"
|
|
|
|
$pendingEml = Join-Path $dirs.Pending "$id.eml"
|
|
$pendingMeta = "$pendingEml.json"
|
|
|
|
# SMTP DATA wird in CRLF normalisiert.
|
|
$raw = ($Lines -join "`r`n") + "`r`n"
|
|
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
|
|
$meta = [ordered]@{
|
|
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
|
|
RemoteAddress = $RemoteAddress
|
|
EnvelopeFrom = $MailFrom
|
|
EnvelopeRecipients = @($Recipients)
|
|
RetryCount = 0
|
|
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
|
|
}
|
|
|
|
try {
|
|
# Erst vollständig in incoming schreiben.
|
|
[IO.File]::WriteAllText($incomingEmlTmp, $raw, $utf8NoBom)
|
|
$meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $incomingMetaTmp -Encoding UTF8
|
|
|
|
# Metadaten zuerst finalisieren, Maildatei zuletzt.
|
|
# Erst wenn die .eml in pending liegt, ist sie für den Worker sichtbar.
|
|
Move-Item -LiteralPath $incomingMetaTmp -Destination $pendingMeta -Force
|
|
Move-Item -LiteralPath $incomingEmlTmp -Destination $pendingEml -Force
|
|
}
|
|
catch {
|
|
Remove-Item -LiteralPath $incomingEmlTmp -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $incomingMetaTmp -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $pendingMeta -Force -ErrorAction SilentlyContinue
|
|
Remove-Item -LiteralPath $pendingEml -Force -ErrorAction SilentlyContinue
|
|
throw
|
|
}
|
|
|
|
Write-Log "Mail angenommen: $id | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress"
|
|
return $pendingEml
|
|
}
|
|
|
|
function Write-SmtpLine {
|
|
param(
|
|
[Parameter(Mandatory)][System.IO.StreamWriter]$Writer,
|
|
[Parameter(Mandatory)][string]$Line
|
|
)
|
|
$Writer.WriteLine($Line)
|
|
$Writer.Flush()
|
|
}
|
|
|
|
function Handle-SmtpClient {
|
|
param([Parameter(Mandatory)][System.Net.Sockets.TcpClient]$Client)
|
|
|
|
$remote = $Client.Client.RemoteEndPoint
|
|
$remoteIp = ([System.Net.IPEndPoint]$remote).Address
|
|
Write-Log "SMTP-Verbindung von $remoteIp"
|
|
|
|
if (-not (Test-ClientAllowed -Address $remoteIp)) {
|
|
try {
|
|
$stream = $Client.GetStream()
|
|
$writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII)
|
|
$writer.NewLine = "`r`n"
|
|
$writer.AutoFlush = $true
|
|
Write-SmtpLine $writer "554 5.7.1 Client not allowed"
|
|
} catch {}
|
|
$Client.Close()
|
|
Write-Log "Client abgewiesen: $remoteIp" "WARN"
|
|
return
|
|
}
|
|
|
|
$stream = $Client.GetStream()
|
|
$stream.ReadTimeout = [int]$script:Config.Smtp.ClientTimeoutSeconds * 1000
|
|
$reader = New-Object System.IO.StreamReader($stream, [System.Text.Encoding]::UTF8, $true, 4096, $true)
|
|
$writer = New-Object System.IO.StreamWriter($stream, [System.Text.Encoding]::ASCII, 4096, $true)
|
|
$writer.NewLine = "`r`n"
|
|
$writer.AutoFlush = $true
|
|
|
|
$mailFrom = $null
|
|
$recipients = New-Object System.Collections.Generic.List[string]
|
|
|
|
Write-SmtpLine $writer ("220 {0} SMTPGraphRelay ready" -f $script:Config.Smtp.Hostname)
|
|
|
|
try {
|
|
while ($Client.Connected) {
|
|
$line = $reader.ReadLine()
|
|
if ($null -eq $line) { break }
|
|
|
|
if ($line -match '^(?i)(EHLO|HELO)\s+(.+)$') {
|
|
Write-SmtpLine $writer ("250-{0}" -f $script:Config.Smtp.Hostname)
|
|
Write-SmtpLine $writer ("250-SIZE {0}" -f ([int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024))
|
|
Write-SmtpLine $writer "250 8BITMIME"
|
|
}
|
|
elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') {
|
|
$mailFrom = $matches[1]
|
|
$recipients.Clear()
|
|
Write-SmtpLine $writer "250 2.1.0 OK"
|
|
}
|
|
elseif ($line -match '^(?i)RCPT TO:\s*<([^>]+)>') {
|
|
if (-not $mailFrom) {
|
|
Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM first"
|
|
continue
|
|
}
|
|
$recipients.Add($matches[1])
|
|
Write-SmtpLine $writer "250 2.1.5 OK"
|
|
}
|
|
elseif ($line -match '^(?i)DATA\s*$') {
|
|
if (-not $mailFrom -or $recipients.Count -eq 0) {
|
|
Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM and RCPT TO first"
|
|
continue
|
|
}
|
|
|
|
Write-SmtpLine $writer "354 End data with <CR><LF>.<CR><LF>"
|
|
$data = New-Object System.Collections.Generic.List[string]
|
|
$size = 0
|
|
$maxBytes = [int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024
|
|
$tooLarge = $false
|
|
|
|
while ($true) {
|
|
$dataLine = $reader.ReadLine()
|
|
if ($null -eq $dataLine) { throw "Client disconnected during DATA" }
|
|
if ($dataLine -eq ".") { break }
|
|
|
|
# SMTP dot-stuffing rückgängig machen
|
|
if ($dataLine.StartsWith("..")) { $dataLine = $dataLine.Substring(1) }
|
|
|
|
$size += [System.Text.Encoding]::UTF8.GetByteCount($dataLine) + 2
|
|
if ($size -gt $maxBytes) {
|
|
$tooLarge = $true
|
|
} elseif (-not $tooLarge) {
|
|
$data.Add($dataLine)
|
|
}
|
|
}
|
|
|
|
if ($tooLarge) {
|
|
Write-SmtpLine $writer "552 5.3.4 Message size exceeds fixed maximum message size"
|
|
Write-Log "Mail von $remoteIp wegen Größenlimit verworfen." "WARN"
|
|
} else {
|
|
[void](Save-SmtpMessage -Lines $data -MailFrom $mailFrom -Recipients $recipients.ToArray() -RemoteAddress $remoteIp.ToString())
|
|
Write-SmtpLine $writer "250 2.0.0 Queued"
|
|
}
|
|
|
|
$mailFrom = $null
|
|
$recipients.Clear()
|
|
}
|
|
elseif ($line -match '^(?i)RSET\s*$') {
|
|
$mailFrom = $null
|
|
$recipients.Clear()
|
|
Write-SmtpLine $writer "250 2.0.0 Reset"
|
|
}
|
|
elseif ($line -match '^(?i)NOOP(?:\s+.*)?$') {
|
|
Write-SmtpLine $writer "250 2.0.0 OK"
|
|
}
|
|
elseif ($line -match '^(?i)QUIT\s*$') {
|
|
Write-SmtpLine $writer "221 2.0.0 Bye"
|
|
break
|
|
}
|
|
elseif ($line -match '^(?i)(AUTH|STARTTLS)\b') {
|
|
Write-SmtpLine $writer "502 5.5.1 Command not implemented"
|
|
}
|
|
else {
|
|
Write-SmtpLine $writer "500 5.5.2 Command unrecognized"
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
Write-Log "SMTP-Clientfehler ${remoteIp}: $($_.Exception.Message)" "WARN"
|
|
}
|
|
finally {
|
|
try { $reader.Dispose() } catch {}
|
|
try { $writer.Dispose() } catch {}
|
|
try { $stream.Dispose() } catch {}
|
|
try { $Client.Close() } catch {}
|
|
}
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $ConfigPath)) {
|
|
throw "Konfiguration nicht gefunden: $ConfigPath. Bitte zuerst Setup-SMTPGraphRelay.ps1 ausführen."
|
|
}
|
|
|
|
$script:Config = Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
|
|
|
New-Item -ItemType Directory -Path (Resolve-PathFromConfig $script:Config.Paths.Logs) -Force | Out-Null
|
|
Initialize-QueueDirectories
|
|
|
|
$listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress)
|
|
$listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port)
|
|
$listener.Start()
|
|
|
|
Write-Log "SMTPGraphRelay gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)"
|
|
Write-Log "Graph-Absender: $($script:Config.Graph.SenderMailbox)"
|
|
|
|
$lastQueueRun = [DateTime]::MinValue
|
|
|
|
try {
|
|
while ($true) {
|
|
if ((Get-Date) -gt $lastQueueRun.AddSeconds([int]$script:Config.Queue.PollSeconds)) {
|
|
try { Process-Queue } catch { Write-Log "Queue-Worker: $($_.Exception.Message)" "ERROR" }
|
|
$lastQueueRun = Get-Date
|
|
}
|
|
|
|
if ($listener.Pending()) {
|
|
$client = $listener.AcceptTcpClient()
|
|
# V1 verarbeitet Clients seriell. Für typische Geräte-/Monitoring-Relays bewusst simpel.
|
|
Handle-SmtpClient -Client $client
|
|
} else {
|
|
Start-Sleep -Milliseconds 200
|
|
}
|
|
}
|
|
}
|
|
finally {
|
|
$listener.Stop()
|
|
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
|
|
Write-Log "SMTPGraphRelay beendet."
|
|
}
|