From cda5c58aa25ea44a696d6e075d560239fcb3e57c Mon Sep 17 00:00:00 2001 From: "manuel.maier" Date: Tue, 13 Oct 2026 14:38:46 +0200 Subject: [PATCH] Dateien nach "/" hochladen --- Setup-SMTPGraphRelay.ps1 | 1164 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1164 insertions(+) create mode 100644 Setup-SMTPGraphRelay.ps1 diff --git a/Setup-SMTPGraphRelay.ps1 b/Setup-SMTPGraphRelay.ps1 new file mode 100644 index 0000000..7cd5855 --- /dev/null +++ b/Setup-SMTPGraphRelay.ps1 @@ -0,0 +1,1164 @@ +[CmdletBinding()] +param( + [string]$InstallPath = "$env:ProgramFiles\SMTPGraphRelay" +) + +#Requires -Version 5.1 +#Requires -RunAsAdministrator +<# +.SYNOPSIS + SMTPGraphRelay Installer / Repair / Update + +.DESCRIPTION + Einheitliches Verwaltungswerkzeug fuer SMTPGraphRelay. + + Modi: + 1 - Neuinstallation + 2 - Installation reparieren + 3 - Relay aktualisieren + 4 - Entra App & Berechtigungen pruefen + 5 - Zertifikat erneuern + 6 - Health Check ausfuehren + 7 - Deinstallieren + + WICHTIG: + Dieses Skript muss mit Windows PowerShell 5.1 ausgefuehrt werden. +#> + +$ErrorActionPreference = "Stop" +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +$TaskName = "SMTPGraphRelay" +$AppDefaultName = "SMTPGraphRelay" +$RelayFileName = "SMTPGraphRelay.ps1" +$HealthFileName = "Test-SMTPGraphRelay.ps1" +$RenewFileName = "Renew-SMTPGraphRelayCertificate.ps1" +$ConfigFileName = "config.json" + +# Zentrales Gitea-Repository / Updatequelle +$RepoBaseUrl = "https://me-gitea.maieredv.cloud/MAIEREDV/SMTPGraphRelay" +$RemoteVersionUrl = "$RepoBaseUrl/raw/branch/main/version.json" +$RemoteArchiveUrl = "$RepoBaseUrl/archive/main.zip" +$RemoteRelayUrl = "$RepoBaseUrl/raw/branch/main/SMTPGraphRelay.ps1" + +$ManagedReleaseFiles = @( + "SMTPGraphRelay.ps1", + "Test-SMTPGraphRelay.ps1", + "Test-SMTPGraphRelay-FailureModes.ps1", + "Renew-SMTPGraphRelayCertificate.ps1", + "Setup-SMTPGraphRelay.ps1", + "version.json", + "README.md" +) + +function Write-Title { + param([string]$Text) + Write-Host "" + Write-Host "==========================================================" -ForegroundColor Cyan + Write-Host " $Text" -ForegroundColor Cyan + Write-Host "==========================================================" -ForegroundColor Cyan + Write-Host "" +} + +function Write-Ok { param([string]$Text) Write-Host "[OK] $Text" -ForegroundColor Green } +function Write-Warn { param([string]$Text) Write-Host "[WARN] $Text" -ForegroundColor Yellow } +function Write-Fail { param([string]$Text) Write-Host "[FAIL] $Text" -ForegroundColor Red } +function Write-Info { param([string]$Text) Write-Host "[INFO] $Text" -ForegroundColor Cyan } + +function Read-Default { + param([string]$Prompt, [string]$Default) + $value = Read-Host "$Prompt [Standard: $Default]" + if ([string]::IsNullOrWhiteSpace($value)) { return $Default } + return $value +} + +function Confirm-Yes { + param([string]$Prompt) + $answer = Read-Host "$Prompt [j/N]" + return ($answer -match '^(?i)j|ja|y|yes$') +} + +function Assert-WindowsPowerShell51 { + if ($PSVersionTable.PSEdition -ne "Desktop" -or $PSVersionTable.PSVersion.Major -ne 5) { + Write-Title "FALSCHE POWERSHELL-VERSION" + Write-Fail "Dieses Tool muss mit Windows PowerShell 5.1 ausgefuehrt werden." + exit 1 + } + Write-Ok "Windows PowerShell $($PSVersionTable.PSVersion) erkannt." +} + +function Ensure-PackageProvider { + try { + if (-not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { + Write-Info "NuGet Package Provider wird installiert..." + Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force | Out-Null + } + } catch { + Write-Warn "NuGet Provider konnte nicht automatisch vorbereitet werden: $($_.Exception.Message)" + } +} + +function Ensure-Modules { + Ensure-PackageProvider + + $modules = @( + "Microsoft.Graph.Authentication", + "Microsoft.Graph.Applications" + ) + + $allUsersPath = Join-Path ($env:ProgramFiles) "WindowsPowerShell\Modules" + + foreach ($module in $modules) { + $existing = Get-Module -ListAvailable -Name ($module) | + Where-Object { $_.ModuleBase -like "$allUsersPath*" } | + Sort-Object Version -Descending | + Select-Object -First 1 + + if (-not $existing) { + Write-Info "$module fehlt (System-weit). Installation fuer AllUsers..." + Install-Module ($module) -Scope AllUsers -Repository PSGallery -Force -AllowClobber + + $existing = Get-Module -ListAvailable -Name ($module) | + Where-Object { $_.ModuleBase -like "$allUsersPath*" } | + Sort-Object Version -Descending | + Select-Object -First 1 + } + + if (-not $existing) { + throw "Modul '$module' konnte nicht fuer AllUsers installiert/gefunden werden." + } + Write-Ok "$module verfuegbar (System-weit)." + } +} + +function New-RepoStagingArea { + Enable-Tls12 + + $root = Join-Path ($env:TEMP) ("SMTPGraphRelay-repo-{0}" -f [guid]::NewGuid().ToString("N")) + $archive = Join-Path ($root) "main.zip" + $extract = Join-Path ($root) "extract" + + New-Item -ItemType Directory -Path ($extract) -Force | Out-Null + + Write-Info "Lade aktuellen Stand aus Gitea..." + Invoke-WebRequest -Uri ($RemoteArchiveUrl) -OutFile ($archive) -UseBasicParsing -TimeoutSec 120 -ErrorAction Stop + + if (-not (Test-Path -LiteralPath ($archive))) { + throw "Gitea-Archiv wurde nicht heruntergeladen." + } + + Expand-Archive -LiteralPath ($archive) -DestinationPath ($extract) -Force + $releaseRoot = Find-ExtractedReleaseRoot -ExtractPath ($extract) + + foreach ($required in @("SMTPGraphRelay.ps1", "version.json")) { + if (-not (Test-Path -LiteralPath (Join-Path ($releaseRoot) $required))) { + throw "Repository-Archiv ist unvollstaendig: '$required' fehlt." + } + } + + $versionInfo = Get-Content -LiteralPath (Join-Path ($releaseRoot) "version.json") -Raw -Encoding UTF8 | ConvertFrom-Json + + if (-not $versionInfo.Version) { + throw "version.json aus dem Repository enthaelt keine Version." + } + + Write-Ok "Repository-Version $($versionInfo.Version) geladen." + + return [pscustomobject]@{ + TempRoot = $root + ReleaseRoot = $releaseRoot + VersionInfo = $versionInfo + } +} + +function Remove-RepoStagingArea { + param($Staging) + if ($Staging -and $Staging.TempRoot) { + Remove-Item -LiteralPath ($Staging.TempRoot) -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Install-RepoProgramFiles { + param( + [Parameter(Mandatory)][string]$ReleaseRoot, + [Parameter(Mandatory)][string]$TargetPath + ) + + New-Item -ItemType Directory -Path ($TargetPath) -Force | Out-Null + + foreach ($name in $ManagedReleaseFiles) { + $source = Join-Path ($ReleaseRoot) $name + if (Test-Path -LiteralPath ($source)) { + Copy-Item -LiteralPath ($source) -Destination (Join-Path ($TargetPath) $name) -Force + Write-Ok "Installiert: $name" + } + } +} + +function Get-SourceFile { + param([Parameter(Mandatory)][string]$Name) + $candidate = Join-Path ($PSScriptRoot) $Name + if (Test-Path -LiteralPath ($candidate)) { + return $candidate + } + return $null +} + +function Get-InstalledVersion { + param([Parameter(Mandatory)][string]$TargetPath) + $versionFile = Join-Path ($TargetPath) "version.json" + if (-not (Test-Path -LiteralPath ($versionFile))) { + return $null + } + try { + return Get-Content -LiteralPath ($versionFile) -Raw -Encoding UTF8 | ConvertFrom-Json + } + catch { + return $null + } +} + +function Ensure-Directories { + param([Parameter(Mandatory)][string]$TargetPath) + + foreach ($dir in @( + $TargetPath, + (Join-Path ($TargetPath) "queue"), + (Join-Path ($TargetPath) "queue\incoming"), + (Join-Path ($TargetPath) "queue\pending"), + (Join-Path ($TargetPath) "queue\processing"), + (Join-Path ($TargetPath) "failed"), + (Join-Path ($TargetPath) "logs") + )) { + New-Item -ItemType Directory -Path ($dir) -Force | Out-Null + } + Write-Ok "Programm-/Queue-/Log-Verzeichnisse vorhanden." +} + +function Get-RelayConfig { + param([Parameter(Mandatory)][string]$TargetPath) + + $path = Join-Path ($TargetPath) $ConfigFileName + if (-not (Test-Path -LiteralPath ($path))) { + return $null + } + try { + return Get-Content -LiteralPath ($path) -Raw -Encoding UTF8 | ConvertFrom-Json + } + catch { + throw "config.json konnte nicht gelesen werden: $($_.Exception.Message)" + } +} + +function Write-RelayConfig { + param( + [Parameter(Mandatory)]$Config, + [Parameter(Mandatory)][string]$TargetPath + ) + + $configPath = Join-Path ($TargetPath) $ConfigFileName + $tmp = "$configPath.tmp" + + $Config | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath ($tmp) -Encoding UTF8 + Move-Item -LiteralPath ($tmp) -Destination ($configPath) -Force + Write-Ok "config.json geschrieben." +} + +function Ensure-FirewallRule { + param([Parameter(Mandatory)][int]$Port) + + $prefix = "SMTPGraphRelay TCP " + Get-NetFirewallRule -ErrorAction SilentlyContinue | + Where-Object { $_.DisplayName -like "$prefix*" -and $_.DisplayName -ne "$prefix$Port" } | + Remove-NetFirewallRule -ErrorAction SilentlyContinue + + $ruleName = "$prefix$Port" + $existing = Get-NetFirewallRule -DisplayName ($ruleName) -ErrorAction SilentlyContinue + + if (-not $existing) { + New-NetFirewallRule -DisplayName ($ruleName) -Direction Inbound -Action Allow -Protocol TCP -LocalPort ($Port) -Profile Any | Out-Null + Write-Ok "Firewallregel '$ruleName' erstellt." + } + else { + Write-Ok "Firewallregel '$ruleName' vorhanden." + } +} + +function Ensure-ScheduledTask { + param([Parameter(Mandatory)][string]$TargetPath) + + $scriptPath = Join-Path ($TargetPath) $RelayFileName + if (-not (Test-Path -LiteralPath ($scriptPath))) { + throw "Relay-Skript fehlt: $scriptPath" + } + + $psExe = "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" + $configPath = Join-Path ($TargetPath) $ConfigFileName + + $action = New-ScheduledTaskAction -Execute ($psExe) -Argument "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`" -ConfigPath `"$configPath`"" -WorkingDirectory ($TargetPath) + $trigger = New-ScheduledTaskTrigger -AtStartup + $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) + + Register-ScheduledTask -TaskName ($TaskName) -Action ($action) -Trigger ($trigger) -Principal ($principal) -Settings ($settings) -Description "Lokaler SMTP Store-and-Forward Relay zu Microsoft 365" -Force | Out-Null + Write-Ok "Scheduled Task '$TaskName' eingerichtet." +} + +function Stop-RelayTask { + $task = Get-ScheduledTask -TaskName ($TaskName) -ErrorAction SilentlyContinue + if (-not $task -or $task.State -ne "Running") { return } + + $graceSeconds = 30 + try { + $config = Get-RelayConfig -TargetPath ($InstallPath) + if ($config -and $config.Smtp.PSObject.Properties.Name -contains "GracefulShutdownSeconds") { + $configured = [int]$config.Smtp.GracefulShutdownSeconds + if ($configured -ge 5 -and $configured -le 300) { $graceSeconds = $configured } + } + } catch {} + + $signalPath = Join-Path ($InstallPath) "shutdown.request" + + try { + [IO.File]::WriteAllText($signalPath, ([DateTime]::UtcNow.ToString("o")), (New-Object Text.UTF8Encoding($false))) + Write-Info "Graceful Shutdown angefordert. Warte auf Relay (max. $graceSeconds Sekunden)..." + $deadline = (Get-Date).AddSeconds($graceSeconds + 5) + + while ((Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 250 + $current = Get-ScheduledTask -TaskName ($TaskName) -ErrorAction SilentlyContinue + if (-not $current -or $current.State -ne "Running") { + Write-Ok "Relay sauber beendet." + return + } + } + Write-Warn "Graceful-Shutdown-Timeout erreicht. Task wird hart beendet." + Stop-ScheduledTask -TaskName ($TaskName) -ErrorAction SilentlyContinue + Start-Sleep -Milliseconds 750 + } + finally { + Remove-Item -LiteralPath ($signalPath) -Force -ErrorAction SilentlyContinue + } +} + +function Start-RelayTask { + $task = Get-ScheduledTask -TaskName ($TaskName) -ErrorAction SilentlyContinue + if ($task) { + Remove-Item -LiteralPath (Join-Path ($InstallPath) "shutdown.request") -Force -ErrorAction SilentlyContinue + Start-ScheduledTask -TaskName ($TaskName) + Start-Sleep -Seconds 2 + Write-Ok "Scheduled Task gestartet." + } +} + +function Convert-CertToKeyCredential { + param([Parameter(Mandatory)][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate) + return @{ + Type = "AsymmetricX509Cert" + Usage = "Verify" + Key = $Certificate.GetRawCertData() + DisplayName = "SMTPGraphRelay Certificate" + StartDateTime = $Certificate.NotBefore.ToUniversalTime() + EndDateTime = $Certificate.NotAfter.ToUniversalTime() + } +} + +function New-RelayCertificate { + $subject = "CN=SMTPGraphRelay-$env:COMPUTERNAME" + return New-SelfSignedCertificate -Subject ($subject) -CertStoreLocation "Cert:\LocalMachine\My" -KeyAlgorithm RSA -KeyLength 2048 -HashAlgorithm SHA256 -KeyExportPolicy NonExportable -KeySpec Signature -NotAfter (Get-Date).AddYears(2) +} + +function Connect-RelayGraphAdmin { + param([string]$TenantId) + Import-Module Microsoft.Graph.Authentication -Force -ErrorAction Stop + Import-Module Microsoft.Graph.Applications -Force -ErrorAction Stop + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + + $scopes = @("Application.ReadWrite.All", "AppRoleAssignment.ReadWrite.All") + if ([string]::IsNullOrWhiteSpace($TenantId)) { + Connect-MgGraph -Scopes ($scopes) -NoWelcome + } else { + Connect-MgGraph -TenantId ($TenantId) -Scopes ($scopes) -NoWelcome + } +} + +function Enable-Tls12 { + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } catch {} +} + +function Get-RemoteVersion { + Enable-Tls12 + $tempFile = Join-Path ($env:TEMP) ("SMTPGraphRelay-version-{0}.json" -f [guid]::NewGuid().ToString("N")) + try { + Invoke-WebRequest -Uri ($RemoteVersionUrl) -OutFile ($tempFile) -UseBasicParsing -TimeoutSec 20 -ErrorAction Stop + $remote = Get-Content -LiteralPath ($tempFile) -Raw -Encoding UTF8 | ConvertFrom-Json + if (-not $remote.Version) { throw "Remote version.json enthaelt keine Version." } + return $remote + } + finally { + Remove-Item -LiteralPath ($tempFile) -Force -ErrorAction SilentlyContinue + } +} + +function Compare-RelayVersions { + param([Parameter(Mandatory)][string]$Installed, [Parameter(Mandatory)][string]$Remote) + try { + $installedVersion = [version]$Installed + $remoteVersion = [version]$Remote + if ($remoteVersion -gt $installedVersion) { return 1 } + if ($remoteVersion -lt $installedVersion) { return -1 } + return 0 + } + catch { + throw "Versionsvergleich fehlgeschlagen: installiert='$Installed', remote='$Remote'." + } +} + +function Find-ExtractedReleaseRoot { + param([Parameter(Mandatory)][string]$ExtractPath) + $relayFiles = Get-ChildItem -LiteralPath ($ExtractPath) -Recurse -File -Filter ($RelayFileName) -ErrorAction SilentlyContinue + foreach ($relay in $relayFiles) { + $candidate = $relay.Directory.FullName + if (Test-Path -LiteralPath (Join-Path ($candidate) "version.json")) { + return $candidate + } + } + throw "Im heruntergeladenen Archiv wurde kein gueltiges SMTPGraphRelay-Release gefunden." +} + +function Backup-ManagedFiles { + param([Parameter(Mandatory)][string]$TargetPath) + $backupRoot = Join-Path ($TargetPath) "backup" + $backupPath = Join-Path ($backupRoot) (Get-Date -Format "yyyyMMdd-HHmmss") + New-Item -ItemType Directory -Path ($backupPath) -Force | Out-Null + foreach ($name in $ManagedReleaseFiles) { + $source = Join-Path ($TargetPath) $name + if (Test-Path -LiteralPath ($source)) { + Copy-Item -LiteralPath ($source) -Destination (Join-Path ($backupPath) $name) -Force + } + } + return $backupPath +} + +function Restore-ManagedFiles { + param([Parameter(Mandatory)][string]$BackupPath, [Parameter(Mandatory)][string]$TargetPath) + foreach ($name in $ManagedReleaseFiles) { + $backupFile = Join-Path ($BackupPath) $name + $targetFile = Join-Path ($TargetPath) $name + if (Test-Path -LiteralPath ($backupFile)) { + Copy-Item -LiteralPath ($backupFile) -Destination ($targetFile) -Force + } + } +} + +function Install-ExtractedRelease { + param([Parameter(Mandatory)][string]$ReleaseRoot, [Parameter(Mandatory)][string]$TargetPath) + $required = @("SMTPGraphRelay.ps1", "version.json") + foreach ($name in $required) { + if (-not (Test-Path -LiteralPath (Join-Path ($ReleaseRoot) $name))) { + throw "Updatepaket ist unvollstaendig: '$name' fehlt." + } + } + foreach ($name in $ManagedReleaseFiles) { + $source = Join-Path ($ReleaseRoot) $name + if (Test-Path -LiteralPath ($source)) { + Copy-Item -LiteralPath ($source) -Destination (Join-Path ($TargetPath) $name) -Force + Write-Ok "Aktualisiert: $name" + } + } +} + +function Invoke-PostUpdateHealthCheck { + param([Parameter(Mandatory)][string]$TargetPath) + $health = Join-Path ($TargetPath) $HealthFileName + if (-not (Test-Path -LiteralPath ($health))) { + Write-Warn "Health Check ist nicht installiert; automatische Nachpruefung entfaellt." + return 0 + } + Write-Info "Starte Health Check nach dem Update..." + & $health -ConfigPath (Join-Path ($TargetPath) $ConfigFileName) + return $LASTEXITCODE +} + +function Install-New { + Write-Title "SMTPGraphRelay - Neuinstallation aus Gitea" + + if (Test-Path -LiteralPath (Join-Path ($InstallPath) $ConfigFileName)) { + Write-Warn "Es existiert bereits eine config.json." + if (-not (Confirm-Yes "Trotzdem fortfahren?")) { return } + } + + $staging = $null + try { + $staging = New-RepoStagingArea + Ensure-Modules + Ensure-Directories -TargetPath ($InstallPath) + Install-RepoProgramFiles -ReleaseRoot ($staging.ReleaseRoot) -TargetPath ($InstallPath) + Write-Ok "Programmdateien aus Gitea installiert." + + $appName = Read-Default "Name der Entra App" $AppDefaultName + $senderMailbox = Read-Host "Standard-Absenderpostfach (Fallback/Test, z.B. info@firma.de)" + while ([string]::IsNullOrWhiteSpace($senderMailbox) -or $senderMailbox -notmatch '^[^@\s]+@[^@\s]+\.[^@\s]+$') { + $senderMailbox = Read-Host "Bitte eine gueltige Mailadresse eingeben" + } + + # NEU: Abfrage für Support-Alarmadresse + $alertMailbox = Read-Default "Support-Mailadresse fuer Zertifikats-Alarme (leer = keine Alarme)" "" + + $listenAddress = Read-Default "Lokale Listen-IP" "0.0.0.0" + $port = [int](Read-Default "SMTP-Port" "2525") + $allowedText = Read-Default "Erlaubte Netze, mit Komma getrennt" "127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" + $allowedNetworks = @($allowedText -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + Write-Info "Erzeuge Relay-Zertifikat..." + $cert = New-RelayCertificate + Write-Ok "Zertifikat erstellt: $($cert.Thumbprint)" + + Write-Info "Microsoft Graph Anmeldung erforderlich (Entra-Admin)." + Connect-RelayGraphAdmin + + try { + $tenantId = (Get-MgContext).TenantId + Write-Ok "Tenant: $tenantId" + + $appParams = @{ + DisplayName = $appName + SignInAudience = "AzureADMyOrg" + KeyCredentials = @((Convert-CertToKeyCredential -Certificate ($cert))) + } + + $app = New-MgApplication -BodyParameter ($appParams) + Write-Ok "App Registration erstellt: $($app.AppId)" + + $sp = $null + for ($i = 0; $i -lt 10 -and -not $sp; $i++) { + try { $sp = New-MgServicePrincipal -AppId ($app.AppId) } catch { Start-Sleep -Seconds 2 } + } + + if (-not $sp) { throw "Entra Service Principal konnte nicht erstellt werden." } + Write-Ok "Entra Service Principal erstellt: $($sp.Id)" + + Write-Info "Erteile globale 'Mail.Send' Application Berechtigung (Tenant-weiter Versand)..." + $graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -Property "id,appRoles" + $mailSendRole = $graphSp.AppRoles | Where-Object { $_.Value -eq "Mail.Send" -and $_.AllowedMemberTypes -contains "Application" } | Select-Object -First 1 + + if (-not $mailSendRole) { throw "Microsoft Graph AppRole 'Mail.Send' konnte nicht gefunden werden." } + + $existingAssig = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId ($sp.Id) -All -ErrorAction SilentlyContinue | Where-Object { $_.AppRoleId -eq $mailSendRole.Id } + + if (-not $existingAssig) { + New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId ($sp.Id) -PrincipalId ($sp.Id) -ResourceId ($graphSp.Id) -AppRoleId ($mailSendRole.Id) | Out-Null + Write-Ok "Globale 'Mail.Send' Application Role erfolgreich vergeben." + } else { + Write-Ok "Globale 'Mail.Send' Berechtigung war bereits vergeben." + } + + $config = [ordered]@{ + Smtp = [ordered]@{ + ListenAddress = $listenAddress + Port = $port + Hostname = $env:COMPUTERNAME + AllowedNetworks = $allowedNetworks + MaxMessageSizeMB = 25 + ClientTimeoutSeconds = 120 + MaxConcurrentClients = 20 + MaxRecipients = 50 + MaxMessagesPerConnection = 25 + RequireAuth = $false + AuthMaxFailures = 5 + AllowUnauthenticatedNetworks = @() + AuthUsers = @() + GracefulShutdownSeconds = 30 + } + Graph = [ordered]@{ + TenantId = $tenantId + ClientId = $app.AppId + CertificateThumbprint = $cert.Thumbprint + SenderMailbox = $senderMailbox + AlertMailbox = $alertMailbox + ForceSender = $false + CertificateWarningDays = 60 + CertificateCriticalDays = 14 + CertificateCheckHours = 12 + } + Queue = [ordered]@{ + PollSeconds = 10 + MaxRetries = 8 + RetryMinutes = @(1,5,15,30,60,120,240,480) + MaxPendingMessages = 5000 + MinFreeDiskSpaceMB = 1024 + } + Paths = [ordered]@{ + Queue = "queue" + Failed = "failed" + Logs = "logs" + } + Logging = [ordered]@{ + MaxFileSizeMB = 10 + RetentionDays = 30 + CleanupHours = 12 + } + Update = [ordered]@{ + Repository = $RepoBaseUrl + Branch = "main" + } + } + + Write-RelayConfig -Config ($config) -TargetPath ($InstallPath) + Ensure-FirewallRule -Port ($port) + Ensure-ScheduledTask -TargetPath ($InstallPath) + + Write-Info "Teste App-only Anmeldung mit Relay-Zertifikat..." + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + Connect-MgGraph -TenantId ($tenantId) -ClientId ($app.AppId) -Certificate ($cert) -NoWelcome | Out-Null + + $ctx = Get-MgContext + if (-not $ctx -or $ctx.AuthType -ne "AppOnly") { throw "App-only Anmeldung konnte nicht bestaetigt werden." } + Write-Ok "App-only Anmeldung funktioniert." + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + + Start-RelayTask + + $health = Join-Path ($InstallPath) $HealthFileName + if (Test-Path -LiteralPath ($health)) { + Write-Info "Starte abschliessenden Health Check..." + & $health -ConfigPath (Join-Path ($InstallPath) $ConfigFileName) + if ($LASTEXITCODE -ge 2) { Write-Warn "Health Check meldet Fehler. Bitte Ausgabe pruefen." } + } + + Write-Title "Neuinstallation abgeschlossen" + Write-Host "Installationspfad: $InstallPath" + } + finally { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + } + } + finally { + Remove-RepoStagingArea -Staging ($staging) + } +} + +function Repair-Installation { + Write-Title "SMTPGraphRelay - Repair aus Gitea" + $config = Get-RelayConfig -TargetPath ($InstallPath) + if (-not $config) { + Write-Fail "Keine config.json gefunden. Repair ist nur fuer bestehende Installationen gedacht." + return + } + + $staging = $null + $backupPath = $null + try { + $staging = New-RepoStagingArea + Ensure-Modules + Ensure-Directories -TargetPath ($InstallPath) + + $certPath = "Cert:\LocalMachine\My\$($config.Graph.CertificateThumbprint)" + $cert = Get-Item -LiteralPath ($certPath) -ErrorAction SilentlyContinue + if (-not $cert) { Write-Fail "Konfiguriertes Zertifikat fehlt." } + elseif (-not $cert.HasPrivateKey) { Write-Fail "Konfiguriertes Zertifikat besitzt keinen privaten Schluessel." } + else { Write-Ok "Zertifikat vorhanden." } + + $backupPath = Backup-ManagedFiles -TargetPath ($InstallPath) + Stop-RelayTask + Install-RepoProgramFiles -ReleaseRoot ($staging.ReleaseRoot) -TargetPath ($InstallPath) + Ensure-FirewallRule -Port ([int]$config.Smtp.Port) + Ensure-ScheduledTask -TargetPath ($InstallPath) + Start-RelayTask + + $healthExit = Invoke-PostUpdateHealthCheck -TargetPath ($InstallPath) + if ($healthExit -ge 2) { throw "Health Check nach Repair meldet FEHLER." } + Write-Title "Repair abgeschlossen" + } + catch { + Write-Fail "Repair fehlgeschlagen: $($_.Exception.Message)" + if ($backupPath -and (Test-Path -LiteralPath ($backupPath))) { + try { + Stop-RelayTask + Restore-ManagedFiles -BackupPath ($backupPath) -TargetPath ($InstallPath) + Ensure-ScheduledTask -TargetPath ($InstallPath) + Start-RelayTask + } catch {} + } + } + finally { + Remove-RepoStagingArea -Staging ($staging) + } +} + +function Update-Relay { + Write-Title "SMTPGraphRelay - Online Update" + $config = Get-RelayConfig -TargetPath ($InstallPath) + if (-not $config) { + Write-Fail "Keine bestehende config.json gefunden." + return + } + + $installedVersionInfo = Get-InstalledVersion -TargetPath ($InstallPath) + $installedVersion = if ($installedVersionInfo) { [string]$installedVersionInfo.Version } else { Read-Host "Installierte Version manuell eingeben" } + if ([string]::IsNullOrWhiteSpace($installedVersion)) { return } + + try { $remoteInfo = Get-RemoteVersion } catch { Write-Fail "Remote-Version konnte nicht geladen werden."; return } + $remoteVersion = [string]$remoteInfo.Version + + $comparison = Compare-RelayVersions -Installed ($installedVersion) -Remote ($remoteVersion) + if ($comparison -eq 0) { Write-Ok "Bereits aktuell ($installedVersion)."; return } + if (-not (Confirm-Yes "Update von $installedVersion auf $remoteVersion durchfuehren?")) { return } + + $updateRoot = Join-Path ($env:TEMP) ("SMTPGraphRelay-update-{0}" -f [guid]::NewGuid().ToString("N")) + $archivePath = Join-Path ($updateRoot) "main.zip" + $extractPath = Join-Path ($updateRoot) "extract" + New-Item -ItemType Directory -Path ($updateRoot) -Force | Out-Null + New-Item -ItemType Directory -Path ($extractPath) -Force | Out-Null + + $backupPath = $null + try { + Invoke-WebRequest -Uri ($RemoteArchiveUrl) -OutFile ($archivePath) -UseBasicParsing -TimeoutSec 120 -ErrorAction Stop + Expand-Archive -LiteralPath ($archivePath) -DestinationPath ($extractPath) -Force + $releaseRoot = Find-ExtractedReleaseRoot -ExtractPath ($extractPath) + + $backupPath = Backup-ManagedFiles -TargetPath ($InstallPath) + Stop-RelayTask + Install-ExtractedRelease -ReleaseRoot ($releaseRoot) -TargetPath ($InstallPath) + Ensure-Directories -TargetPath ($InstallPath) + Ensure-FirewallRule -Port ([int]$config.Smtp.Port) + Ensure-ScheduledTask -TargetPath ($InstallPath) + Start-RelayTask + + $healthExit = Invoke-PostUpdateHealthCheck -TargetPath ($InstallPath) + if ($healthExit -ge 2) { throw "Health Check nach Update meldet FEHLER." } + Write-Title "Online Update abgeschlossen" + } + catch { + Write-Fail "Update fehlgeschlagen: $($_.Exception.Message)" + if ($backupPath -and (Test-Path -LiteralPath ($backupPath))) { + try { + Stop-RelayTask + Restore-ManagedFiles -BackupPath ($backupPath) -TargetPath ($InstallPath) + Ensure-ScheduledTask -TargetPath ($InstallPath) + Start-RelayTask + } catch {} + } + } + finally { + Remove-Item -LiteralPath ($updateRoot) -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Verify-CloudPermissions { + Write-Title "SMTPGraphRelay - Entra Berechtigungen pruefen" + $config = Get-RelayConfig -TargetPath ($InstallPath) + if (-not $config) { return } + + Ensure-Modules + Connect-RelayGraphAdmin -TenantId ($config.Graph.TenantId) + + try { + $app = Get-MgApplication -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName" | Select-Object -First 1 + if (-not $app) { Write-Fail "App Registration nicht gefunden."; return } + + $sp = Get-MgServicePrincipal -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id,appId,displayName" | Select-Object -First 1 + if (-not $sp) { Write-Fail "Entra Service Principal nicht gefunden."; return } + + $graphSp = Get-MgServicePrincipal -Filter "appId eq '00000003-0000-0000-c000-000000000000'" -Property "id,appRoles" + $mailSendRole = $graphSp.AppRoles | Where-Object { $_.Value -eq "Mail.Send" -and $_.AllowedMemberTypes -contains "Application" } | Select-Object -First 1 + + $globalAssig = Get-MgServicePrincipalAppRoleAssignment -ServicePrincipalId ($sp.Id) -All -ErrorAction SilentlyContinue | Where-Object { $_.AppRoleId -eq $mailSendRole.Id } + + if ($globalAssig) { Write-Ok "GLOBALE (tenant-weite) Mail.Send Berechtigung vorhanden." } + else { Write-Fail "GLOBALE Mail.Send Berechtigung fehlt!" } + } + finally { + Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null + } +} + +function Invoke-CertificateRenewal { + $renew = Join-Path ($InstallPath) $RenewFileName + if (-not (Test-Path -LiteralPath ($renew))) { return } + & $renew -ConfigPath (Join-Path ($InstallPath) $ConfigFileName) +} + +function Invoke-HealthCheck { + $health = Join-Path ($InstallPath) $HealthFileName + if (-not (Test-Path -LiteralPath ($health))) { return } + & $health -ConfigPath (Join-Path ($InstallPath) $ConfigFileName) +} + +function Invoke-Pbkdf2Sha256 { + param([Parameter(Mandatory)][string]$Password, [Parameter(Mandatory)][byte[]]$Salt, [Parameter(Mandatory)][int]$Iterations, [int]$Length = 32) + 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 New-SmtpPasswordRecord { + param([Parameter(Mandatory)][string]$Password) + $salt = New-Object byte[] 16 + $rng = [Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($salt) } finally { $rng.Dispose() } + + $iterations = 150000 + $hash = Invoke-Pbkdf2Sha256 -Password ($Password) -Salt ($salt) -Iterations ($iterations) -Length 32 + return [pscustomobject]@{ Salt = [Convert]::ToBase64String($salt); PasswordHash = [Convert]::ToBase64String($hash); Iterations = $iterations } +} + +function Ensure-SmtpAuthConfig { + param([Parameter(Mandatory)]$Config) + if (-not ($Config.Smtp.PSObject.Properties.Name -contains "RequireAuth")) { $Config.Smtp | Add-Member -NotePropertyName RequireAuth -NotePropertyValue $false } + if (-not ($Config.Smtp.PSObject.Properties.Name -contains "AuthMaxFailures")) { $Config.Smtp | Add-Member -NotePropertyName AuthMaxFailures -NotePropertyValue 5 } + if (-not ($Config.Smtp.PSObject.Properties.Name -contains "AllowUnauthenticatedNetworks")) { $Config.Smtp | Add-Member -NotePropertyName AllowUnauthenticatedNetworks -NotePropertyValue @() } + if (-not ($Config.Smtp.PSObject.Properties.Name -contains "AuthUsers")) { $Config.Smtp | Add-Member -NotePropertyName AuthUsers -NotePropertyValue @() } + return $Config +} + +function Save-SmtpAuthConfigAndRestart { + param([Parameter(Mandatory)]$Config) + Write-RelayConfig -Config ($Config) -TargetPath ($InstallPath) + Ensure-ScheduledTask -TargetPath ($InstallPath) + Stop-RelayTask + Start-RelayTask +} + +function Get-FailedQueuePath { + param([Parameter(Mandatory)]$Config) + $path = [string]$Config.Paths.Failed + if ([IO.Path]::IsPathRooted($path)) { return $path } + return (Join-Path ($InstallPath) $path) +} + +function Get-PendingQueuePath { + param([Parameter(Mandatory)]$Config) + $path = [string]$Config.Paths.Queue + if (-not [IO.Path]::IsPathRooted($path)) { $path = Join-Path ($InstallPath) $path } + return (Join-Path ($path) "pending") +} + +function Get-FailedQueueEntries { + param([Parameter(Mandatory)]$Config) + $failedPath = Get-FailedQueuePath -Config ($Config) + if (-not (Test-Path -LiteralPath ($failedPath))) { return @() } + $entries = @() + foreach ($file in Get-ChildItem -LiteralPath ($failedPath) -Filter "*.eml" -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime) { + $metaPath = "$($file.FullName).json" + $meta = $null + if (Test-Path -LiteralPath ($metaPath)) { try { $meta = Get-Content -LiteralPath ($metaPath) -Raw -Encoding UTF8 | ConvertFrom-Json } catch {} } + + $queueId = [IO.Path]::GetFileNameWithoutExtension($file.Name) + if ($meta -and $meta.PSObject.Properties.Name -contains "QueueId" -and $meta.QueueId) { $queueId = [string]$meta.QueueId } + + $entries += [pscustomobject]@{ + QueueId = $queueId + FileName = $file.Name + Path = $file.FullName + MetaPath = $metaPath + SizeKB = [Math]::Round($file.Length / 1KB, 1) + FailedSince = $file.LastWriteTime + RetryCount = if ($meta -and $meta.RetryCount -ne $null) { [int]$meta.RetryCount } else { $null } + From = if ($meta) { [string]$meta.EnvelopeFrom } else { "" } + Recipients = if ($meta) { (@($meta.EnvelopeRecipients) -join ", ") } else { "" } + LastStatusCode = if ($meta) { [string]$meta.LastStatusCode } else { "" } + LastError = if ($meta) { [string]$meta.LastError } else { "" } + AuthenticatedUser = if ($meta -and $meta.PSObject.Properties.Name -contains "AuthenticatedUser") { [string]$meta.AuthenticatedUser } else { "" } + Meta = $meta + } + } + return @($entries) +} + +function Retry-FailedQueueEntry { + param([Parameter(Mandatory)]$Config, [Parameter(Mandatory)]$Entry) + $pendingPath = Get-PendingQueuePath -Config ($Config) + New-Item -ItemType Directory -Path ($pendingPath) -Force | Out-Null + $targetEml = Join-Path ($pendingPath) $Entry.FileName + $targetMeta = "$targetEml.json" + + if (Test-Path -LiteralPath ($targetEml)) { throw "Pending enthaelt bereits '$($Entry.FileName)'." } + + if ($Entry.Meta) { + $meta = $Entry.Meta + if ($meta.PSObject.Properties.Name -contains "RetryCount") { $meta.RetryCount = 0 } else { $meta | Add-Member -NotePropertyName RetryCount -NotePropertyValue 0 } + $now = [DateTime]::UtcNow.ToString("o") + if ($meta.PSObject.Properties.Name -contains "NextAttemptUtc") { $meta.NextAttemptUtc = $now } else { $meta | Add-Member -NotePropertyName NextAttemptUtc -NotePropertyValue $now } + if ($meta.PSObject.Properties.Name -contains "RequeuedUtc") { $meta.RequeuedUtc = $now } else { $meta | Add-Member -NotePropertyName RequeuedUtc -NotePropertyValue $now } + $meta | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath ($Entry.MetaPath) -Encoding UTF8 + } + + if (Test-Path -LiteralPath ($Entry.MetaPath)) { Move-Item -LiteralPath ($Entry.MetaPath) -Destination ($targetMeta) -Force } + Move-Item -LiteralPath ($Entry.Path) -Destination ($targetEml) -Force +} + +function Remove-FailedQueueEntry { + param([Parameter(Mandatory)]$Entry) + Remove-Item -LiteralPath ($Entry.Path) -Force -ErrorAction Stop + Remove-Item -LiteralPath ($Entry.MetaPath) -Force -ErrorAction SilentlyContinue +} + +function Manage-FailedQueue { + $config = Get-RelayConfig -TargetPath ($InstallPath) + if (-not $config) { Write-Fail "config.json nicht gefunden."; return } + + while ($true) { + Clear-Host + Write-Title "SMTPGraphRelay - Failed Queue" + $entries = @(Get-FailedQueueEntries -Config ($config)) + + Write-Host " [1] Failed Queue anzeigen" + Write-Host " [2] Details einer Mail anzeigen" + Write-Host " [3] Eine Mail erneut zustellen" + Write-Host " [4] Alle Mails erneut zustellen" + Write-Host " [5] Eine Mail endgueltig loeschen" + Write-Host " [6] Alle Failed-Mails endgueltig loeschen" + Write-Host " [0] Zurueck`n" + + $choice = Read-Host "Auswahl" + + switch ($choice) { + "1" { if ($entries.Count -eq 0) { Write-Ok "Failed Queue ist leer." } else { $entries | Select-Object QueueId,FailedSince,RetryCount,From,Recipients,LastStatusCode,SizeKB | Format-Table -AutoSize }; Read-Host "Enter" } + "2" { + $id = Read-Host "Queue-ID" + $entry =$entries | Where-Object { $_.QueueId -eq$id -or $_.FileName -eq$id -or $_.FileName -eq "$id.eml" } | Select-Object -First 1 + if (-not $entry) { Write-Fail "Queue-ID '$id' nicht gefunden." } + else { Write-Host "`nFehler: $($entry.LastError)" -ForegroundColor Yellow } + Read-Host "Enter" + } + "3" { + $id = Read-Host "Queue-ID" + $entry = $entries | Where-Object { $_.QueueId -eq $id -or $_.FileName -eq $id -or $_.FileName -eq "$id.eml" } | Select-Object -First 1 + if ($entry) { Retry-FailedQueueEntry -Config ($config) -Entry ($entry); Write-Ok "[$($entry.QueueId)] zurueck nach pending verschoben." } + Read-Host "Enter" + } + "4" { + if (Confirm-Yes "Alle Mails erneut zustellen?") { + foreach ($entry in $entries) { try { Retry-FailedQueueEntry -Config ($config) -Entry ($entry) } catch {} } + } + } + "5" { + $id = Read-Host "Queue-ID" + $entry = $entries | Where-Object { $_.QueueId -eq $id -or $_.FileName -eq $id -or $_.FileName -eq "$id.eml" } | Select-Object -First 1 + if ($entry -and (Confirm-Yes "Loeschen?")) { Remove-FailedQueueEntry -Entry ($entry); Write-Ok "Geloescht." } + Read-Host "Enter" + } + "6" { + if (Confirm-Yes "Alle loeschen?") { foreach ($entry in $entries) { Remove-FailedQueueEntry -Entry ($entry) } } + } + "0" { return } + } + $config = Get-RelayConfig -TargetPath ($InstallPath) + } +} + +function Manage-SmtpAuth { + $config = Get-RelayConfig -TargetPath ($InstallPath); + if (-not $config) { Write-Fail "config.json nicht gefunden."; return; } + $config = Ensure-SmtpAuthConfig -Config ($config); + + while ($true) { + Clear-Host; + Write-Title "SMTPGraphRelay - SMTP-AUTH"; + + $users = @($config.Smtp.AuthUsers); + $authState = if ([bool]$config.Smtp.RequireAuth) { "ERFORDERLICH" } else { "optional / nicht erforderlich" }; + + Write-Host "Status: $authState"; + Write-Host "Benutzer: $($users.Count)"; + Write-Host "Max. Fehlversuche: $($config.Smtp.AuthMaxFailures)"; + Write-Host "Ohne Auth erlaubte Netze: $(@($config.Smtp.AllowUnauthenticatedNetworks) -join ', ')"; + Write-Host ""; + Write-Host " [1] SMTP-AUTH erforderlich EIN/AUS"; + Write-Host " [2] Benutzer hinzufuegen"; + Write-Host " [3] Benutzer anzeigen"; + Write-Host " [4] Passwort aendern"; + Write-Host " [5] Benutzer loeschen"; + Write-Host " [6] Netze ohne Auth verwalten"; + Write-Host " [7] Max. Fehlversuche aendern"; + Write-Host " [0] Zurueck`n"; + + $choice = Read-Host "Auswahl"; + + switch ($choice) { + "1" { + $config.Smtp.RequireAuth = -not [bool]$config.Smtp.RequireAuth; + if ($config.Smtp.RequireAuth -and @($config.Smtp.AuthUsers).Count -eq 0) { + Write-Warn "AUTH wurde aktiviert, aber es existiert noch kein SMTP-Benutzer."; + } + Save-SmtpAuthConfigAndRestart -Config ($config); + Write-Ok "Status geaendert."; Read-Host "Enter"; + } + "2" { + $username = Read-Host "Benutzername"; + if ([string]::IsNullOrWhiteSpace($username) -or $username -notmatch '^[A-Za-z0-9._@-]{1,128}$') { Write-Fail "Ungueltiger Benutzername."; Read-Host "Enter"; continue; } + $existing = @($config.Smtp.AuthUsers) | Where-Object { ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1; + if ($existing) { Write-Fail "Benutzer existiert bereits."; Read-Host "Enter"; continue; } + + $plain1 = Read-Host "Passwort"; + $plain2 = Read-Host "Passwort wiederholen"; + if ($plain1.Length -lt 8 -or $plain1 -cne$plain2) { Write-Fail "Passwort ungueltig (mind. 8 Zeichen oder stimmen nicht ueberein)."; Read-Host "Enter"; continue; } + + $record = New-SmtpPasswordRecord -Password ($plain1); + $newUser = [pscustomobject]@{ Username =$username; Salt = $record.Salt; PasswordHash =$record.PasswordHash; Iterations = $record.Iterations };$config.Smtp.AuthUsers = @($config.Smtp.AuthUsers) + @($newUser); + Save-SmtpAuthConfigAndRestart -Config ($config); + Write-Ok "Angelegt."; Read-Host "Enter"; + } + "3" { + if (@($config.Smtp.AuthUsers).Count -eq 0) { Write-Warn "Keine SMTP-Benutzer vorhanden."; } else { @($config.Smtp.AuthUsers) | Format-Table Username,Iterations -AutoSize; } + Read-Host "Enter"; + } + "4" { + $username = Read-Host "Benutzername"; + $user = @($config.Smtp.AuthUsers) | Where-Object { ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1; + if (-not $user) { Write-Fail "Benutzer nicht gefunden."; Read-Host "Enter"; continue; } + + $plain1 = Read-Host "Neues Passwort"; + $plain2 = Read-Host "Passwort wiederholen"; + if ($plain1.Length -lt 8 -or $plain1 -cne$plain2) { Write-Fail "Passwort ungueltig (mind. 8 Zeichen oder stimmen nicht ueberein)."; Read-Host "Enter"; continue; } + + $record = New-SmtpPasswordRecord -Password ($plain1); + $user.Salt =$record.Salt; $user.PasswordHash =$record.PasswordHash; $user.Iterations =$record.Iterations; + Save-SmtpAuthConfigAndRestart -Config ($config); + Write-Ok "Geaendert."; Read-Host "Enter"; + } + "5" { + $username = Read-Host "Benutzername"; + $found = @($config.Smtp.AuthUsers) | Where-Object { ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) }; + if (-not $found) { Write-Fail "Benutzer nicht gefunden."; Read-Host "Enter"; continue; } + if (Confirm-Yes "Loeschen?") { + $config.Smtp.AuthUsers = @($config.Smtp.AuthUsers | Where-Object { -not ([string]$_.Username).Equals($username, [StringComparison]::OrdinalIgnoreCase) }); + Save-SmtpAuthConfigAndRestart -Config ($config); + Write-Ok "Geloescht."; + } + Read-Host "Enter"; + } + "6" { + $current = @($config.Smtp.AllowUnauthenticatedNetworks) -join ","; + $input = Read-Default "Netze/IPs ohne AUTH" $(if ($current) {$current } else { "-" }); + if ($input -eq "-") { $config.Smtp.AllowUnauthenticatedNetworks = @(); } else { $config.Smtp.AllowUnauthenticatedNetworks = @($input -split "," | ForEach-Object { $_.Trim() } \vert{} Where-Object {$_ }); } + Save-SmtpAuthConfigAndRestart -Config ($config); + } + "7" { + $value = Read-Host "Max. Fehlversuche"; + if ($value -match '^\d+$') {$config.Smtp.AuthMaxFailures = [int]$value; Save-SmtpAuthConfigAndRestart -Config ($config); } + } + "0" { return; } + } + $config = Get-RelayConfig -TargetPath ($InstallPath); + $config = Ensure-SmtpAuthConfig -Config ($config); + } +} + +function Invoke-FailureModeTests { + $tester = Join-Path ($InstallPath) "Test-SMTPGraphRelay-FailureModes.ps1" + if (Test-Path -LiteralPath ($tester)) { & $tester -InstallPath ($InstallPath) } +} + +function Uninstall-Relay { + Write-Title "SMTPGraphRelay - Deinstallation" + $config = Get-RelayConfig -TargetPath ($InstallPath) + if (-not (Confirm-Yes "Wirklich entfernen?")) { return } + + Stop-RelayTask + Unregister-ScheduledTask -TaskName ($TaskName) -Confirm:$false -ErrorAction SilentlyContinue + Get-NetFirewallRule -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "SMTPGraphRelay TCP *" } | Remove-NetFirewallRule -ErrorAction SilentlyContinue + + if ($config -and$config.Graph.CertificateThumbprint -and (Confirm-Yes "Zertifikat entfernen?")) { + Remove-Item -LiteralPath "Cert:\LocalMachine\My\$($config.Graph.CertificateThumbprint)" -Force -ErrorAction SilentlyContinue + } + if ($config -and (Confirm-Yes "Entra-App entfernen?")) { + Ensure-Modules + Connect-RelayGraphAdmin -TenantId ($config.Graph.TenantId) + try { + $app = Get-MgApplication -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id" | Select-Object -First 1 + $sp = Get-MgServicePrincipal -Filter "appId eq '$($config.Graph.ClientId)'" -Property "id" | Select-Object -First 1 + if ($sp) { Remove-MgServicePrincipal -ServicePrincipalId ($sp.Id) -ErrorAction SilentlyContinue } + if ($app) { Remove-MgApplication -ApplicationId ($app.Id) -ErrorAction SilentlyContinue } + } finally { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } + } + + $currentInstaller = [IO.Path]::GetFullPath($PSCommandPath) + foreach ($item in Get-ChildItem -LiteralPath ($InstallPath) -Force -ErrorAction SilentlyContinue) { + if ($item.FullName -ne $currentInstaller) { Remove-Item -LiteralPath ($item.FullName) -Recurse -Force -ErrorAction SilentlyContinue } + } + Write-Ok "Programmdateien entfernt." +} + +function Show-Status { + Write-Title "SMTPGraphRelay - Status anzeigen" + $configPath = Join-Path ($InstallPath)$ConfigFileName + Write-Host "Installationspfad: $InstallPath" + + if (Test-Path -LiteralPath ($configPath)) { + try { + $config = Get-RelayConfig -TargetPath ($InstallPath) + Write-Host " Sender: $($config.Graph.SenderMailbox)" + Write-Host " Alert: $($config.Graph.AlertMailbox)" + Write-Host " SMTP: $($config.Smtp.ListenAddress):$($config.Smtp.Port)" + Write-Host " AUTH: $([bool]$config.Smtp.RequireAuth)" + Write-Host " Failed: $(@(Get-FailedQueueEntries -Config ($config)).Count) Mail(s)" + } catch {} + } + $task = Get-ScheduledTask -TaskName ($TaskName) -ErrorAction SilentlyContinue + if ($task) { Write-Host "Task State: $($task.State)" } +} + +function Show-Menu { + Clear-Host + Write-Title "SMTPGraphRelay - Verwaltung" + Write-Host " [1] Neuinstallation aus Gitea" + Write-Host " [2] Installation reparieren" + Write-Host " [3] Online Update" + Write-Host " [4] Entra Berechtigungen pruefen" + Write-Host " [5] Zertifikat erneuern" + Write-Host " [6] Health Check" + Write-Host " [7] Deinstallieren" + Write-Host " [8] Status anzeigen" + Write-Host " [9] SMTP-AUTH verwalten" + Write-Host " [10] Failed Queue" + Write-Host " [0] Beenden`n" +} + +Assert-WindowsPowerShell51 +while ($true) { + Show-Menu + $choice = Read-Host "Auswahl" + try { + switch ($choice) { + "1" { Install-New } "2" { Repair-Installation } "3" { Update-Relay } "4" { Verify-CloudPermissions } "5" { Invoke-CertificateRenewal } + "6" { Invoke-HealthCheck } "7" { Uninstall-Relay } "8" { Show-Status } "9" { Manage-SmtpAuth } "10" { Manage-FailedQueue } "0" { break } + } + } catch { Write-Fail $_.Exception.Message } + if ($choice -ne "0") { Read-Host "`nEnter druecken..." } + if ($choice -eq "0") { break } +} \ No newline at end of file