Dateien nach "/" hochladen

This commit is contained in:
2026-08-13 23:51:34 +02:00
parent c166743d9f
commit bc4359435d
+280 -20
View File
@@ -7,7 +7,7 @@
Nimmt lokale SMTP-Mails an, speichert sie als .eml in einer Queue und sendet sie
anschließend per Microsoft Graph sendMail mit App-only Zertifikatsauthentifizierung.
V1.5: Log-Rotation, Zertifikatsüberwachung, parallele SMTP-Clients, Queue-Worker und robuste Graph-Retry-Logik
V1.6: Queue-ID/Received/Message-ID sowie SMTP-/Queue-Backpressure und Session-Limits
#>
[CmdletBinding()]
@@ -550,6 +550,7 @@ function Send-QueuedMail {
$dirs = Get-QueueDirectories
$metaPath = "$FilePath.json"
$meta = $null
$queueId = [IO.Path]::GetFileNameWithoutExtension($FilePath)
if (Test-Path -LiteralPath $metaPath) {
try {
@@ -561,12 +562,17 @@ function Send-QueuedMail {
if (-not $meta) {
$meta = [pscustomobject]@{
QueueId = $queueId
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
RetryCount = 0
NextAttemptUtc = [DateTime]::UtcNow.ToString("o")
}
}
if ($meta -and $meta.PSObject.Properties.Name -contains "QueueId" -and -not [string]::IsNullOrWhiteSpace([string]$meta.QueueId)) {
$queueId = [string]$meta.QueueId
}
$bytes = [IO.File]::ReadAllBytes($FilePath)
if ($script:Config.Graph.ForceSender) {
@@ -589,7 +595,7 @@ function Send-QueuedMail {
# 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)"
Write-Log ("[{0}] Mail von Graph angenommen (202 Accepted)." -f $queueId)
Remove-Item -LiteralPath $FilePath -Force
if (Test-Path -LiteralPath $metaPath) {
@@ -616,9 +622,10 @@ function Send-QueuedMail {
}
if (-not $decision.Retry) {
Write-Log ("Graph-Versand permanent fehlgeschlagen ({0}, {1}): {2}" -f $statusText, $decision.Reason, $failure.Message) "ERROR"
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
@@ -636,9 +643,10 @@ function Send-QueuedMail {
}
if ($retryCount -ge $maxRetries) {
Write-Log ("Graph-Versand nach {0} Versuchen endgültig fehlgeschlagen ({1}): {2}" -f $retryCount, $statusText, $failure.Message) "ERROR"
Write-Log ("[{0}] Graph-Versand nach {1} Versuchen endgültig fehlgeschlagen ({2}): {3}" -f $queueId, $retryCount, $statusText, $failure.Message) "ERROR"
$newMeta = [ordered]@{
QueueId = $queueId
ReceivedUtc = $meta.ReceivedUtc
RemoteAddress = $meta.RemoteAddress
EnvelopeFrom = $meta.EnvelopeFrom
@@ -657,6 +665,7 @@ function Send-QueuedMail {
$next = (Get-Date).AddSeconds([int]$decision.DelaySeconds)
$newMeta = [ordered]@{
QueueId = $queueId
ReceivedUtc = $meta.ReceivedUtc
RemoteAddress = $meta.RemoteAddress
EnvelopeFrom = $meta.EnvelopeFrom
@@ -676,8 +685,8 @@ function Send-QueuedMail {
"$($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"
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
@@ -729,6 +738,185 @@ function Process-Queue {
}
}
function New-QueueId {
# Kurz genug für Logs/SMTP-Antworten, aber mit sehr hoher Kollisionsreserve.
return ([guid]::NewGuid().ToString("N").Substring(0,12).ToUpperInvariant())
}
function Add-RelayMessageHeaders {
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[AllowEmptyString()]
[System.Collections.Generic.List[string]]$Lines,
[Parameter(Mandatory)]
[string]$QueueId,
[Parameter(Mandatory)]
[string]$RemoteAddress
)
$result = New-Object System.Collections.Generic.List[string]
# Header/Body-Grenze suchen. Falls eine kaputte Minimalnachricht keine Leerzeile
# besitzt, behandeln wir den kompletten Inhalt als Headerblock und ergänzen die
# notwendige Trennzeile.
$separatorIndex = -1
for ($i = 0; $i -lt $Lines.Count; $i++) {
if ($Lines[$i] -eq "") {
$separatorIndex = $i
break
}
}
$headerEnd = if ($separatorIndex -ge 0) { $separatorIndex } else { $Lines.Count }
$hasMessageId = $false
for ($i = 0; $i -lt $headerEnd; $i++) {
if ($Lines[$i] -match '^(?i)Message-ID\s*:') {
$hasMessageId = $true
break
}
}
$hostname = [string]$script:Config.Smtp.Hostname
if ([string]::IsNullOrWhiteSpace($hostname)) {
$hostname = $env:COMPUTERNAME
}
$date = [DateTimeOffset]::Now.ToString("ddd, dd MMM yyyy HH:mm:ss zzz", [Globalization.CultureInfo]::InvariantCulture)
$received = "Received: from [$RemoteAddress] by $hostname with SMTPGraphRelay; $date"
# Received immer ganz oben vor die ursprünglichen Header setzen.
$result.Add($received)
if (-not $hasMessageId) {
$safeHost = ($hostname -replace '[^A-Za-z0-9._-]', '-')
$messageId = "<$QueueId.$([DateTime]::UtcNow.ToString('yyyyMMddHHmmss'))@$safeHost>"
$result.Add("Message-ID: $messageId")
}
for ($i = 0; $i -lt $Lines.Count; $i++) {
$result.Add($Lines[$i])
}
if ($separatorIndex -lt 0) {
$result.Add("")
}
return $result
}
function 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
try {
$pendingCount = @(
Get-ChildItem -LiteralPath $dirs.Pending -Filter "*.eml" -File -ErrorAction Stop
).Count
}
catch {
return [pscustomobject]@{
Accept = $false
SmtpCode = "451 4.3.0 SMTPGraphRelay queue temporarily unavailable"
Reason = "Pending-Queue konnte nicht gelesen werden: $($_.Exception.Message)"
PendingCount = $null
FreeSpaceMB = $null
}
}
if ($pendingCount -ge $limits.MaxPendingMessages) {
return [pscustomobject]@{
Accept = $false
SmtpCode = "452 4.3.1 SMTPGraphRelay queue limit reached"
Reason = "Pending-Queue-Limit erreicht ($pendingCount/$($limits.MaxPendingMessages))"
PendingCount = $pendingCount
FreeSpaceMB = $null
}
}
$freeSpaceMB = $null
try {
$fullQueuePath = [IO.Path]::GetFullPath($dirs.Root)
$root = [IO.Path]::GetPathRoot($fullQueuePath)
if ($root) {
$driveInfo = New-Object System.IO.DriveInfo($root)
if ($driveInfo.IsReady) {
$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)]
@@ -743,23 +931,34 @@ function Save-SmtpMessage {
[string[]]$Recipients,
[Parameter(Mandatory)]
[string]$RemoteAddress
[string]$RemoteAddress,
[string]$QueueId
)
$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"
if ([string]::IsNullOrWhiteSpace($QueueId)) {
$QueueId = New-QueueId
}
$pendingEml = Join-Path $dirs.Pending "$id.eml"
$incomingEmlTmp = Join-Path $dirs.Incoming "$QueueId.eml.tmp"
$incomingMetaTmp = Join-Path $dirs.Incoming "$QueueId.eml.json.tmp"
$pendingEml = Join-Path $dirs.Pending "$QueueId.eml"
$pendingMeta = "$pendingEml.json"
$messageLines = Add-RelayMessageHeaders `
-Lines $Lines `
-QueueId $QueueId `
-RemoteAddress $RemoteAddress
# SMTP DATA wird in CRLF normalisiert.
$raw = ($Lines -join "`r`n") + "`r`n"
$raw = ($messageLines -join "`r`n") + "`r`n"
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$meta = [ordered]@{
QueueId = $QueueId
ReceivedUtc = [DateTime]::UtcNow.ToString("o")
RemoteAddress = $RemoteAddress
EnvelopeFrom = $MailFrom
@@ -786,8 +985,11 @@ function Save-SmtpMessage {
throw
}
Write-Log "Mail angenommen: $id | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress"
return $pendingEml
Write-Log "[$QueueId] Mail angenommen | Von=$MailFrom | An=$($Recipients -join ', ') | Client=$RemoteAddress"
return [pscustomobject]@{
QueueId = $QueueId
Path = $pendingEml
}
}
function Write-SmtpLine {
@@ -828,6 +1030,8 @@ function Handle-SmtpClient {
$mailFrom = $null
$recipients = New-Object System.Collections.Generic.List[string]
$acceptedMessages = 0
$limits = Get-SmtpLimits
Write-SmtpLine $writer ("220 {0} SMTPGraphRelay ready" -f $script:Config.Smtp.Hostname)
@@ -842,6 +1046,12 @@ function Handle-SmtpClient {
Write-SmtpLine $writer "250 8BITMIME"
}
elseif ($line -match '^(?i)MAIL FROM:\s*<([^>]*)>') {
if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) {
Write-SmtpLine $writer "452 4.5.3 Too many messages in this session"
Write-Log ("SMTP-Session von {0}: Nachrichtenlimit {1} erreicht." -f $remoteIp, $limits.MaxMessagesPerConnection) "WARN"
continue
}
$mailFrom = $matches[1]
$recipients.Clear()
Write-SmtpLine $writer "250 2.1.0 OK"
@@ -851,6 +1061,13 @@ function Handle-SmtpClient {
Write-SmtpLine $writer "503 5.5.1 Need MAIL FROM first"
continue
}
if ($recipients.Count -ge $limits.MaxRecipients) {
Write-SmtpLine $writer "452 4.5.3 Too many recipients"
Write-Log ("SMTP-Session von {0}: Empfängerlimit {1} erreicht." -f $remoteIp, $limits.MaxRecipients) "WARN"
continue
}
$recipients.Add($matches[1])
Write-SmtpLine $writer "250 2.1.5 OK"
}
@@ -860,6 +1077,20 @@ function Handle-SmtpClient {
continue
}
if ($acceptedMessages -ge $limits.MaxMessagesPerConnection) {
Write-SmtpLine $writer "452 4.5.3 Too many messages in this session"
continue
}
# Backpressure VOR 354/DATA: Der Client soll große Nachrichtendaten
# gar nicht erst übertragen, wenn wir sie nicht sicher puffern können.
$pressure = Get-QueuePressure
if (-not $pressure.Accept) {
Write-SmtpLine $writer $pressure.SmtpCode
Write-Log ("SMTP-Backpressure für {0}: {1}" -f $remoteIp, $pressure.Reason) "WARN"
continue
}
Write-SmtpLine $writer "354 End data with <CR><LF>.<CR><LF>"
$data = New-Object System.Collections.Generic.List[string]
$size = 0
@@ -877,7 +1108,8 @@ function Handle-SmtpClient {
$size += [System.Text.Encoding]::UTF8.GetByteCount($dataLine) + 2
if ($size -gt $maxBytes) {
$tooLarge = $true
} elseif (-not $tooLarge) {
}
elseif (-not $tooLarge) {
$data.Add($dataLine)
}
}
@@ -885,9 +1117,28 @@ function Handle-SmtpClient {
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"
}
else {
$queueId = New-QueueId
try {
$saved = Save-SmtpMessage `
-Lines $data `
-MailFrom $mailFrom `
-Recipients $recipients.ToArray() `
-RemoteAddress $remoteIp.ToString() `
-QueueId $queueId
$acceptedMessages++
Write-SmtpLine $writer ("250 2.0.0 Message accepted for delivery; queue-id={0}" -f $saved.QueueId)
}
catch {
# Nach DATA darf niemals 250 gesendet werden, wenn die Queue-Datei
# nicht vollständig und atomar gesichert werden konnte.
Write-SmtpLine $writer "451 4.3.0 SMTPGraphRelay queue temporarily unavailable"
Write-Log ("[{0}] Queue-Speicherung fehlgeschlagen | Client={1} | Fehler={2}" -f `
$queueId, $remoteIp, $_.Exception.Message) "ERROR"
}
}
$mailFrom = $null
@@ -924,7 +1175,6 @@ function Handle-SmtpClient {
}
}
function Get-RelayCertificateStatus {
$thumbprint = [string]$script:Config.Graph.CertificateThumbprint
@@ -1172,6 +1422,10 @@ $smtpFunctionNames = @(
"Test-IPv4InCidr",
"Test-ClientAllowed",
"Get-QueueDirectories",
"New-QueueId",
"Add-RelayMessageHeaders",
"Get-SmtpLimits",
"Get-QueuePressure",
"Save-SmtpMessage",
"Write-SmtpLine",
"Handle-SmtpClient"
@@ -1251,11 +1505,17 @@ $listenIp = [System.Net.IPAddress]::Parse($script:Config.Smtp.ListenAddress)
$listener = [System.Net.Sockets.TcpListener]::new($listenIp, [int]$script:Config.Smtp.Port)
$listener.Start()
Write-Log "SMTPGraphRelay V1.5 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)"
Write-Log "SMTPGraphRelay V1.6 gestartet auf $($script:Config.Smtp.ListenAddress):$($script:Config.Smtp.Port)"
Write-Log "Graph-Absender: $($script:Config.Graph.SenderMailbox)"
Write-Log "Maximale parallele SMTP-Verbindungen: $script:MaxConcurrentClients"
Write-Log "Queue-/Graph-Worker läuft separat vom SMTP-Listener."
$limits = Get-SmtpLimits
Write-Log ("SMTP-Limits: max. {0} Empfänger/Mail, {1} Mails/Verbindung." -f `
$limits.MaxRecipients, $limits.MaxMessagesPerConnection)
Write-Log ("Backpressure: max. {0} Pending-Mails, mindestens {1} MB freier Speicher." -f `
$limits.MaxPendingMessages, $limits.MinFreeDiskSpaceMB)
$logSettings = Get-LogSettings
Write-Log ("Log-Rotation: max. {0} MB pro Datei, Aufbewahrung {1} Tage." -f `
$logSettings.MaxFileSizeMB, $logSettings.RetentionDays)