1446 lines
66 KiB
PowerShell
1446 lines
66 KiB
PowerShell
[CmdletBinding()]
|
|
param([string]$ConfigPath = "$PSScriptRoot\config.json");
|
|
|
|
#Requires -Version 5.1
|
|
|
|
$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);
|
|
$files = Get-ChildItem -LiteralPath ($logDir) -File -Filter "SMTPGraphRelay-*.log" -ErrorAction SilentlyContinue;
|
|
|
|
foreach ($file in $files) {
|
|
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";
|
|
|
|
$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);
|
|
|
|
$allowed = @($script:Config.Smtp.AllowedNetworks);
|
|
foreach ($entry in $allowed) {
|
|
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
|
|
);
|
|
|
|
$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;
|
|
$folderList = @($dirs.Root, $dirs.Incoming, $dirs.Pending, $dirs.Processing, $dirs.Failed);
|
|
|
|
foreach ($dir in $folderList) {
|
|
New-Item -ItemType Directory -Path ($dir) -Force | Out-Null;
|
|
}
|
|
|
|
$oldFiles = Get-ChildItem -LiteralPath ($dirs.Root) -Filter "*.eml" -File -ErrorAction SilentlyContinue;
|
|
foreach ($file in $oldFiles) {
|
|
$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)";
|
|
}
|
|
|
|
$procFiles = Get-ChildItem -LiteralPath ($dirs.Processing) -Filter "*.eml" -File -ErrorAction SilentlyContinue;
|
|
foreach ($file in $procFiles) {
|
|
$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 zurueck 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) {
|
|
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 {}
|
|
|
|
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 {}
|
|
|
|
if ($null -eq $statusCode) {
|
|
$msgOut = $ErrorRecord | Out-String;
|
|
$combined = "$message $msgOut";
|
|
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;
|
|
|
|
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"; };
|
|
}
|
|
$seconds = [Math]::Min(3600, [Math]::Pow(2, [Math]::Min($RetryCount, 10)) * 5);
|
|
return [pscustomobject]@{ Retry = $true; DelaySeconds = [int]$seconds; Reason = "Graph 429 / exponentielles Backoff"; };
|
|
}
|
|
|
|
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 = "temporaerer HTTP-Fehler $statusCode"; };
|
|
}
|
|
|
|
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"; };
|
|
}
|
|
|
|
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"; };
|
|
}
|
|
|
|
if ($statusCode -ge 400 -and $statusCode -le 499) {
|
|
return [pscustomobject]@{ Retry = $false; DelaySeconds = 0; Reason = "unbekannter Clientfehler $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 = "unbekannter temporaerer 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";
|
|
$leaf = Split-Path ($SourceEml) -Leaf;
|
|
$destinationEml = Join-Path ($DestinationDirectory) $leaf;
|
|
$destinationMeta = "$destinationEml.json";
|
|
|
|
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: $($_.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") {
|
|
$queueIdString = [string]$meta.QueueId;
|
|
if (-not [string]::IsNullOrWhiteSpace($queueIdString)) { $queueId = $queueIdString; }
|
|
}
|
|
|
|
$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. verzoegert." -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;
|
|
|
|
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 endgueltig 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;
|
|
$pendingFiles = Get-ChildItem -LiteralPath ($dirs.Pending) -Filter "*.eml" -File;
|
|
$sortedFiles = $pendingFiles | Sort-Object CreationTimeUtc;
|
|
|
|
foreach ($file in $sortedFiles) {
|
|
if ($script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath") {
|
|
$sigPath = [string]$script:Config.ShutdownSignalPath;
|
|
if (Test-Path -LiteralPath ($sigPath)) {
|
|
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 fuer $($file.Name) fehlgeschlagen: $($_.Exception.Message)" "ERROR";
|
|
$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 fuer $($file.Name): $($_.Exception.Message)" "ERROR"; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function New-QueueId {
|
|
$guidStr = [guid]::NewGuid().ToString("N");
|
|
$subStr = $guidStr.Substring(0,12);
|
|
return $subStr.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];
|
|
$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";
|
|
|
|
$result.Add($received);
|
|
|
|
if (-not $hasMessageId) {$safeHost = ($hostname -replace '[^A-Za-z0-9._-]', '-');$utcNowStr = [DateTime]::UtcNow.ToString('yyyyMMddHHmmss');
|
|
$messageId = "<$QueueId.$utcNowStr@$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
|
|
);
|
|
|
|
$list = @($Entries);
|
|
foreach ($entry in$list) {
|
|
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."; }
|
|
|
|
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 {
|
|
$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) { 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 ($Writer) -Line "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 ($Writer) -Line "334 UGFzc3dvcmQ6";
|
|
$encodedPassword =$Reader.ReadLine();
|
|
if ($null -eq$encodedPassword) { throw "Client disconnected during AUTH LOGIN"; }
|
|
|
|
$password = ConvertFrom-Base64Utf8 -Value ($encodedPassword);$isValid = Test-SmtpCredentials -Username ($username) -Password ($password);
|
|
return [pscustomobject]@{ Valid = $isValid; 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 ($Writer) -Line "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"; }
|
|
|
|
$username = $parts[1];
|
|
$password = $parts[2];
|
|
$isValid = Test-SmtpCredentials -Username ($username) -Password ($password);
|
|
return [pscustomobject]@{ Valid = $isValid; 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 {
|
|
$pendingFiles = Get-ChildItem -LiteralPath ($dirs.Pending) -Filter "*.eml" -File -ErrorAction Stop;
|
|
$pendingCount = @($pendingFiles).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);
|
|
$raw = ($messageLines -join "`r`n") + "`r`n";
|
|
|
|
$latin1 = [System.Text.Encoding]::GetEncoding(28591);
|
|
$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";
|
|
}
|
|
[IO.File]::WriteAllText($incomingEmlTmp, $raw, $latin1);
|
|
$meta | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath ($incomingMetaTmp) -Encoding UTF8;
|
|
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; };
|
|
$rcptStr = $Recipients -join ', ';
|
|
Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$rcptStr | 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 ($writer) -Line "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;
|
|
$latin1 = [System.Text.Encoding]::GetEncoding(28591);
|
|
|
|
$reader = New-Object System.IO.StreamReader($stream, $latin1, $false, 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);
|
|
$usersCount = @($authSettings.Users).Count;
|
|
$authAvailable = ($usersCount -gt 0);
|
|
|
|
Write-SmtpLine -Writer ($writer) -Line ("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 ($writer) -Line ("250-{0}" -f $script:Config.Smtp.Hostname);
|
|
$sz = [int64]$script:Config.Smtp.MaxMessageSizeMB * 1024 * 1024;
|
|
Write-SmtpLine -Writer ($writer) -Line ("250-SIZE {0}" -f $sz);
|
|
if ($authAvailable) { Write-SmtpLine -Writer ($writer) -Line "250-AUTH LOGIN PLAIN"; }
|
|
Write-SmtpLine -Writer ($writer) -Line "250 8BITMIME";
|
|
}
|
|
elseif ($line -match '^(?i)HELO\s+(.+)$') {
|
|
Write-SmtpLine -Writer ($writer) -Line ("250 {0}" -f $script:Config.Smtp.Hostname);
|
|
}
|
|
elseif ($line -match '^(?i)AUTH\s+LOGIN(?:\s+(\S+))?\s*$') {
|
|
if (-not $authAvailable) { Write-SmtpLine -Writer ($writer) -Line "504 5.7.4 Authentication mechanism unavailable"; continue; }
|
|
if ($authenticated) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.0 Already authenticated"; continue; }
|
|
if ($authLocked) { Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "501 5.5.2 Invalid authentication data";
|
|
} elseif ($result.Valid) {
|
|
$authenticated = $true;
|
|
$authenticatedUser = $result.Username;
|
|
$authFailures = 0;
|
|
Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "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 ($writer) -Line "504 5.7.4 Authentication mechanism unavailable"; continue; }
|
|
if ($authenticated) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.0 Already authenticated"; continue; }
|
|
if ($authLocked) { Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "501 5.5.2 Invalid authentication data";
|
|
} elseif ($result.Valid) {
|
|
$authenticated = $true;
|
|
$authenticatedUser = $result.Username;
|
|
$authFailures = 0;
|
|
Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "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 ($writer) -Line "504 5.5.4 Unsupported authentication mechanism";
|
|
}
|
|
elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') {
|
|
if ($authRequired -and -not $authenticated) { Write-SmtpLine -Writer ($writer) -Line "530 5.7.0 Authentication required"; continue; }
|
|
if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) {
|
|
Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "250 2.1.0 OK";
|
|
}
|
|
elseif ($line -match '^(?i)RCPT TO:\s*<([^>]+)>') {
|
|
if (-not $mailFrom) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.1 Need MAIL FROM first"; continue; }
|
|
if ($recipients.Count -ge $limits.MaxRecipients) {
|
|
Write-SmtpLine -Writer ($writer) -Line "452 4.5.3 Too many recipients";
|
|
Write-Log ("SMTP-Session von {0}: Empfaengerlimit {1} erreicht." -f $remoteIp, $limits.MaxRecipients) "WARN";
|
|
continue;
|
|
}
|
|
$recipients.Add($matches[1]);
|
|
Write-SmtpLine -Writer ($writer) -Line "250 2.1.5 OK";
|
|
}
|
|
elseif ($line -match '^(?i)DATA\s*$') {
|
|
if ($authRequired -and -not $authenticated) { Write-SmtpLine -Writer ($writer) -Line "530 5.7.0 Authentication required"; continue; }
|
|
if (-not $mailFrom -or $recipients.Count -eq 0) { Write-SmtpLine -Writer ($writer) -Line "503 5.5.1 Need MAIL FROM and RCPT TO first"; continue; }
|
|
if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) { Write-SmtpLine -Writer ($writer) -Line "452 4.5.3 Too many messages in this session"; continue; }
|
|
|
|
$pressure = Get-QueuePressure;
|
|
if (-not $pressure.Accept) {
|
|
Write-SmtpLine -Writer ($writer) -Line ($pressure.SmtpCode);
|
|
Write-Log ("SMTP-Backpressure fuer {0}: {1}" -f $remoteIp, $pressure.Reason) "WARN";
|
|
continue;
|
|
}
|
|
|
|
Write-SmtpLine -Writer ($writer) -Line "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 += $latin1.GetByteCount($dataLine) + 2;
|
|
|
|
if ($size -gt $maxBytes) { $tooLarge = $true; } elseif (-not $tooLarge) { $data.Add($dataLine); }
|
|
}
|
|
|
|
if ($tooLarge) {
|
|
Write-SmtpLine -Writer ($writer) -Line "552 5.3.4 Message size exceeds fixed maximum message size";
|
|
Write-Log "Mail von $remoteIp wegen Groessenlimit verworfen." "WARN";
|
|
} else {
|
|
$queueId = New-QueueId;
|
|
try {
|
|
$rcptArr = $recipients.ToArray();
|
|
$rmtIpStr = $remoteIp.ToString();
|
|
$saved = Save-SmtpMessage -Lines ($data) -MailFrom ($mailFrom) -Recipients ($rcptArr) -RemoteAddress ($rmtIpStr) -AuthenticatedUser ($authenticatedUser) -QueueId ($queueId);
|
|
$acceptedMessages++;
|
|
Write-SmtpLine -Writer ($writer) -Line ("250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId);
|
|
} catch {
|
|
Write-SmtpLine -Writer ($writer) -Line "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 ($writer) -Line "250 2.0.0 Reset";
|
|
}
|
|
elseif ($line -match '^(?i)NOOP(?:\s+.*)?$') { Write-SmtpLine -Writer ($writer) -Line "250 2.0.0 OK"; }
|
|
elseif ($line -match '^(?i)QUIT\s*$') { Write-SmtpLine -Writer ($writer) -Line "221 2.0.0 Bye"; break; }
|
|
elseif ($line -match '^(?i)STARTTLS\b') { Write-SmtpLine -Writer ($writer) -Line "502 5.5.1 Command not implemented"; }
|
|
else { Write-SmtpLine -Writer ($writer) -Line "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 Schluessel."; }
|
|
|
|
$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 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);
|
|
}
|
|
|
|
return $status;
|
|
} catch {
|
|
Write-Log ("Zertifikatspruefung 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 {
|
|
$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 fuer {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 ausfuehren.";
|
|
}
|
|
|
|
$script:Config = Get-Content -LiteralPath ($ConfigPath) -Raw -Encoding UTF8 | ConvertFrom-Json;
|
|
$fullConfPath = [IO.Path]::GetFullPath($ConfigPath);
|
|
$script:InstallRoot = Split-Path -Parent ($fullConfPath);
|
|
$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 {}
|
|
}
|
|
|
|
Remove-Item -LiteralPath ($script:ShutdownSignalPath) -Force -ErrorAction SilentlyContinue;
|
|
$logResPath = Resolve-PathFromConfig ($script:Config.Paths.Logs);
|
|
New-Item -ItemType Directory -Path ($logResPath) -Force | Out-Null;
|
|
Initialize-QueueDirectories;
|
|
Invoke-LogMaintenance -Force;
|
|
[void](Test-RelayCertificateExpiry -ForceLog);
|
|
|
|
$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);
|
|
|
|
$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 "Ungueltiges Smtp.MaxConcurrentClients; verwende Standard 20." "WARN"; }
|
|
} catch { Write-Log "Smtp.MaxConcurrentClients konnte nicht gelesen werden; verwende Standard 20." "WARN"; }
|
|
}
|
|
|
|
$script:WorkerConfig = New-WorkerConfig;
|
|
|
|
$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;
|
|
|
|
$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") {
|
|
`$sigPathStr = [string]`$script:Config.ShutdownSignalPath;
|
|
if (Test-Path -LiteralPath (`$sigPathStr)) { Write-Log "Queue-Worker: Shutdown-Signal erkannt."; break; }
|
|
}
|
|
try { Process-Queue; } catch { Write-Log ("Queue-Worker: {0}" -f `$_.Exception.Message) "ERROR"; }
|
|
`$sleepUntil = (Get-Date).AddSeconds([int]`$script:Config.Queue.PollSeconds);
|
|
while ((Get-Date) -lt `$sleepUntil) {
|
|
if (`$script:Config.PSObject.Properties.Name -contains "ShutdownSignalPath") {
|
|
`$sigPathStr = [string]`$script:Config.ShutdownSignalPath;
|
|
if (Test-Path -LiteralPath (`$sigPathStr)) { 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();
|
|
|
|
$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 laeuft separat vom SMTP-Listener.";
|
|
|
|
$limits = Get-SmtpLimits;
|
|
Write-Log ("SMTP-Limits: max. {0} Empfaenger/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"; };
|
|
$userCount = @($authSettings.Users).Count;
|
|
Write-Log ("SMTP-AUTH: {0}; {1} Benutzer; max. {2} Fehlversuche/Verbindung." -f $authMode, $userCount,$authSettings.AuthMaxFailures);
|
|
Write-Log ("Graceful Shutdown: bis zu {0} Sekunden fuer 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);
|
|
}
|
|
|
|
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 faehrt kontrolliert herunter...";
|
|
try { $listener.Stop(); } catch {}
|
|
|
|
$deadline = (Get-Date).AddSeconds($script:GracefulShutdownSeconds);
|
|
|
|
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 {}
|
|
|
|
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.";
|
|
} |