src/notifiers/NotifyEmail.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Email notifications via Microsoft Graph API (OAuth2). .DESCRIPTION Sends course alerts via email using Azure AD OAuth2 + Microsoft Graph /me/sendMail endpoint. Includes token caching, auto-refresh, and retry logic. #> # ============================================================================ # CONSTANTS # ============================================================================ $OAUTH_TOKEN_ENDPOINT = "https://login.microsoftonline.com/{0}/oauth2/v2.0/token" $GRAPH_API_SCOPE = "https://graph.microsoft.com/.default" # Load System.Web for HttpUtility if (-not ([System.Management.Automation.PSTypeName]'System.Web.HttpUtility').Type) { Add-Type -AssemblyName System.Web | Out-Null } # Load System.Security for ProtectedData if (-not ([System.Management.Automation.PSTypeName]'System.Security.Cryptography.ProtectedData').Type) { Add-Type -AssemblyName System.Security | Out-Null } # ============================================================================ # PRIVATE FUNCTIONS (Helpers) # ============================================================================ function _GetCredentialFromStore { <# .SYNOPSIS Load Client Secret from encrypted credential store #> param([string]$StorePath) try { $credentialFile = Join-Path $StorePath "IPSC-Kurs-Watcher-Secret.bin" if (-not (Test-Path $credentialFile)) { Write-Log -Level ERROR -Message "Credential file not found" ` -Context @{ path = $credentialFile } return $null } $encryptedBytes = [System.IO.File]::ReadAllBytes($credentialFile) $decryptedBytes = [System.Security.Cryptography.ProtectedData]::Unprotect( $encryptedBytes, $null, [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) $secret = [System.Text.Encoding]::UTF8.GetString($decryptedBytes) return $secret } catch { Write-Log -Level ERROR -Message "Failed to load credential from store" -Exception $_ return $null } } function _LoadTokenCache { <# .SYNOPSIS Load cached OAuth2 token from encrypted file #> param([string]$CachePath) try { if (-not (Test-Path $CachePath)) { return $null } $encryptedBytes = [System.IO.File]::ReadAllBytes($CachePath) $decryptedBytes = [System.Security.Cryptography.ProtectedData]::Unprotect( $encryptedBytes, [System.Text.Encoding]::UTF8.GetBytes("IPSC-Token-Cache-v1"), [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) $tokenJson = [System.Text.Encoding]::UTF8.GetString($decryptedBytes) $cached = $tokenJson | ConvertFrom-Json return $cached } catch { Write-Log -Level WARN -Message "Failed to load token cache" -Exception $_ return $null } } function _SaveTokenCache { <# .SYNOPSIS Save OAuth2 token to encrypted cache file #> param([object]$Token, [string]$CachePath) try { $cacheDir = Split-Path -Path $CachePath -Parent if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } $tokenJson = $Token | ConvertTo-Json $tokenBytes = [System.Text.Encoding]::UTF8.GetBytes($tokenJson) $encryptedBytes = [System.Security.Cryptography.ProtectedData]::Protect( $tokenBytes, [System.Text.Encoding]::UTF8.GetBytes("IPSC-Token-Cache-v1"), [System.Security.Cryptography.DataProtectionScope]::LocalMachine ) [System.IO.File]::WriteAllBytes($CachePath, $encryptedBytes) } catch { Write-Log -Level WARN -Message "Failed to save token cache" -Exception $_ } } function _IsTokenExpired { <# .SYNOPSIS Check if OAuth2 token is expired or expiring soon #> param([object]$Token) if (-not $Token -or -not $Token.expires_on) { return $true } try { $unixEpoch = [DateTime]'1970-01-01' $expiresOn = $unixEpoch.AddSeconds([int]$Token.expires_on) $bufferSeconds = 60 $expiryThreshold = $expiresOn.AddSeconds(-$bufferSeconds) return (Get-Date) -gt $expiryThreshold } catch { return $true } } function _RefreshOAuthToken { <# .SYNOPSIS Request new OAuth2 token from Azure AD #> param( [string]$TenantId, [string]$ClientId, [string]$ClientSecret, [int]$TimeoutSeconds = 30, [int]$MaxRetries = 3 ) $attempt = 0 while ($attempt -lt $MaxRetries) { $attempt++ try { $tokenUri = $OAUTH_TOKEN_ENDPOINT -f $TenantId $body = @{ client_id = $ClientId client_secret = $ClientSecret scope = $GRAPH_API_SCOPE grant_type = "client_credentials" } $response = Invoke-SecureWebRequest -Uri $tokenUri -Method POST -Body $body ` -Headers @{ "Content-Type" = "application/x-www-form-urlencoded" } ` -TimeoutSeconds $TimeoutSeconds $token = $response.Content | ConvertFrom-Json $unixEpoch = [DateTime]'1970-01-01' $expiresOn = [int]([DateTime]::UtcNow - $unixEpoch).TotalSeconds + $token.expires_in $token | Add-Member -NotePropertyName expires_on -NotePropertyValue $expiresOn -Force return $token } catch { $waitSeconds = [Math]::Pow(2, $attempt - 1) $errorMsg = $_.Exception.Message $sanitizedError = Protect-OAuthError -ErrorMessage $errorMsg if ($attempt -lt $MaxRetries) { Write-Log -Level WARN -Message "OAuth2 token refresh failed, retrying" ` -Context @{ attempt = $attempt; max_retries = $MaxRetries; wait_seconds = $waitSeconds; error = $sanitizedError } Start-Sleep -Seconds $waitSeconds } else { Write-Log -Level ERROR -Message "OAuth2 token refresh failed on last attempt" ` -Context @{ attempt = $attempt; error = $sanitizedError } } } } Write-Log -Level ERROR -Message "OAuth2 token refresh failed after all retries" ` -Context @{ max_retries = $MaxRetries } return $null } function _GetAlertEmoji { <# .SYNOPSIS Get emoji for alert type #> param([string]$AlertReason) switch ($AlertReason) { 'NEW' { return '[NEW]' } 'AVAILABILITY_REDUCED' { return '[REDUCED]' } 'SOLD_OUT' { return '[SOLD_OUT]' } default { return '[ALERT]' } } } function _GetAlertColor { <# .SYNOPSIS Get color hex for alert type #> param([string]$AlertReason) switch ($AlertReason) { 'NEW' { return '#10b981' } 'AVAILABILITY_REDUCED' { return '#f59e0b' } 'SOLD_OUT' { return '#ef4444' } default { return '#3b82f6' } } } function _BuildEmailBody { <# .SYNOPSIS Build HTML email body from alerts #> param([object[]]$Alerts) $html = @" <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <style> body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1a1a1a; line-height: 1.6; } .container { max-width: 600px; margin: 0 auto; padding: 20px; } .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 8px 8px 0 0; text-align: center; } .header h1 { margin: 0; font-size: 24px; } .header p { margin: 10px 0 0 0; opacity: 0.9; font-size: 14px; } .alert-group { background: #f9fafb; border-left: 4px solid; margin: 20px 0; padding: 15px; border-radius: 4px; } .alert-group.new { border-left-color: #10b981; background: #f0fdf4; } .alert-group.reduced { border-left-color: #f59e0b; background: #fffbeb; } .alert-group.sold_out { border-left-color: #ef4444; background: #fef2f2; } .alert-group-title { font-weight: 600; margin-bottom: 12px; font-size: 14px; } .alert-group.new .alert-group-title { color: #10b981; } .alert-group.reduced .alert-group-title { color: #f59e0b; } .alert-group.sold_out .alert-group-title { color: #ef4444; } .course { background: white; padding: 12px; margin: 8px 0; border-radius: 4px; border: 1px solid #e5e7eb; } .course-name { font-weight: 600; color: #1a1a1a; margin: 0 0 6px 0; } .course-detail { font-size: 13px; color: #666; margin: 4px 0; } .course-link { display: inline-block; margin-top: 8px; padding: 8px 16px; background: #3b82f6; color: white; text-decoration: none; border-radius: 4px; font-size: 13px; } .course-link:hover { background: #2563eb; } .footer { text-align: center; color: #999; font-size: 12px; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; } </style> </head> <body> <div class="container"> <div class="header"> <h1>IPSC Kurs Watcher</h1> <p>Neue Kurse und Verfügbarkeitsänderungen</p> </div> "@ # Group alerts by reason $grouped = $Alerts | Group-Object -Property alert_reason foreach ($group in $grouped) { $reason = $group.Name $cssClass = switch ($reason) { 'NEW' { 'new' } 'AVAILABILITY_REDUCED' { 'reduced' } 'SOLD_OUT' { 'sold_out' } default { 'new' } } $title = switch ($reason) { 'NEW' { 'Neue Kurse' } 'AVAILABILITY_REDUCED' { 'Verfügbarkeit reduziert' } 'SOLD_OUT' { 'Ausgebucht' } default { 'Kurse' } } $html += @" <div class="alert-group $cssClass"> <div class="alert-group-title">$title ($($group.Count))</div> "@ foreach ($alert in $group.Group) { $html += @" <div class="course"> <div class="course-name">$([System.Web.HttpUtility]::HtmlEncode($alert.name))</div> <div class="course-detail">Datum: $([System.Web.HttpUtility]::HtmlEncode($alert.date)) | Zeit: $([System.Web.HttpUtility]::HtmlEncode($alert.time))</div> <div class="course-detail">Verfügbarkeit: $($alert.availability) Plätze | Preis: $([System.Web.HttpUtility]::HtmlEncode($alert.price))</div> <a href="$([System.Web.HttpUtility]::HtmlEncode($alert.url))" class="course-link">Kurs anschauen</a> </div> "@ } $html += @" </div> "@ } $html += @" <div class="footer"> <p>IPSC Kurs Watcher | shooting-store.ch</p> <p>Diese Benachrichtigung wurde automatisch generiert.</p> </div> </div> </body> </html> "@ return $html } function _SendMailViaGraph { <# .SYNOPSIS Send email via Microsoft Graph API #> param( [string]$AccessToken, [string]$UserId, [string[]]$Recipients, [string]$Subject, [string]$HtmlBody, [int]$TimeoutSeconds = 30, [int]$MaxRetries = 3 ) $attempt = 0 while ($attempt -lt $MaxRetries) { try { $attempt++ $recipientList = @() foreach ($recipient in $Recipients) { $recipientList += @{ emailAddress = @{ address = $recipient } } } $payload = @{ message = @{ subject = $Subject body = @{ contentType = "HTML" content = $HtmlBody } toRecipients = $recipientList } } $headers = @{ Authorization = "Bearer $AccessToken" "Content-Type" = "application/json; charset=utf-8" } $jsonBody = $payload | ConvertTo-Json -Depth 10 $jsonBodyBytes = [System.Text.Encoding]::UTF8.GetBytes($jsonBody) $sendMailUri = "https://graph.microsoft.com/v1.0/users/$UserId/sendMail" if (-not (Test-ValidUrl -Url $sendMailUri)) { Write-Log -Level ERROR -Message "Invalid sendMail URI detected" -Context @{ uri = $sendMailUri } return $false } Invoke-SecureWebRequest -Uri $sendMailUri -Method POST ` -Headers $headers -Body $jsonBodyBytes ` -TimeoutSeconds $TimeoutSeconds | Out-Null Write-Log -Level INFO -Message "Email sent successfully" ` -Context @{ recipients = $Recipients.Count } return $true } catch { $waitSeconds = [Math]::Pow(2, $attempt - 1) if ($attempt -lt $MaxRetries) { Write-Log -Level WARN -Message "Email send failed, retrying" ` -Context @{ attempt = $attempt; max_retries = $MaxRetries; wait_seconds = $waitSeconds; error = $_.Exception.Message } Start-Sleep -Seconds $waitSeconds } else { Write-Log -Level ERROR -Message "Email send failed after all retries" ` -Context @{ max_retries = $MaxRetries; error = $_.Exception.Message } } } } return $false } # ============================================================================ # PUBLIC FUNCTIONS # ============================================================================ function Get-AzureOAuthToken { <# .SYNOPSIS Get valid OAuth2 token for Microsoft Graph API. .DESCRIPTION Returns cached token if valid, otherwise refreshes from Azure AD. Implements automatic refresh, caching, and retry logic. .PARAMETER TenantId Azure AD Tenant ID .PARAMETER ClientId Azure AD Application (Client) ID .PARAMETER ClientSecret Azure AD Client Secret (from Credential Store) .PARAMETER CachePath Path to token cache file (default: data/.token_cache.json) .PARAMETER TimeoutSeconds Request timeout in seconds (default: 30) .EXAMPLE $token = Get-AzureOAuthToken -TenantId "..." -ClientId "..." -ClientSecret "..." if ($token) { $authHeader = @{ Authorization = "Bearer $($token.access_token)" } } .OUTPUTS PSObject with access_token, expires_on, or $null on failure #> [CmdletBinding()] param( [ValidateNotNullOrEmpty()][string]$TenantId, [ValidateNotNullOrEmpty()][string]$ClientId, [ValidateNotNullOrEmpty()][string]$ClientSecret, [ValidateNotNullOrEmpty()][string]$CachePath = "data/.token_cache.json", [ValidateRange(1, 600)][int]$TimeoutSeconds = 30 ) try { # Try to load cached token $cached = _LoadTokenCache -CachePath $CachePath if ($cached -and -not (_IsTokenExpired -Token $cached)) { Write-Log -Level DEBUG -Message "Using cached OAuth2 token" return $cached } # Refresh token Write-Log -Level INFO -Message "Refreshing OAuth2 token" $newToken = _RefreshOAuthToken -TenantId $TenantId -ClientId $ClientId ` -ClientSecret $ClientSecret -TimeoutSeconds $TimeoutSeconds if ($newToken) { _SaveTokenCache -Token $newToken -CachePath $CachePath return $newToken } return $null } catch { Write-Log -Level ERROR -Message "Failed to get OAuth2 token" -Exception $_ throw } } function Send-EmailNotification { <# .SYNOPSIS Send email notifications for course alerts via Microsoft Graph API. .PARAMETER Alerts Array of alert objects with alert_reason (NEW, AVAILABILITY_REDUCED, SOLD_OUT) .PARAMETER Config Email configuration from config.json .EXAMPLE Send-EmailNotification -Alerts $alerts -Config $config.notifiers.email .OUTPUTS None #> [CmdletBinding(SupportsShouldProcess)] param([ValidateNotNull()][object[]]$Alerts, [ValidateNotNull()][object]$Config) if (-not $Config.enabled) { return } if (-not $Alerts -or $Alerts.Count -eq 0) { return } if (-not $PSCmdlet.ShouldProcess("email recipients", "Send $($Alerts.Count) course alerts")) { return } try { # Get Azure credentials from environment variables $tenantId = $env:IPSC_AZURE_TENANT_ID $clientId = $env:IPSC_AZURE_CLIENT_ID $senderEmail = $env:IPSC_EMAIL_SENDER $recipientEmailsStr = $env:IPSC_EMAIL_RECIPIENTS $credStorePath = $env:IPSC_CREDENTIAL_STORE_PATH # Validate required environment variables if (-not $tenantId -or -not $clientId) { Write-Log -Level WARN -Message "Email notifier disabled: missing IPSC_AZURE_TENANT_ID or IPSC_AZURE_CLIENT_ID environment variables" return } if (-not $senderEmail) { Write-Log -Level WARN -Message "Email notifier disabled: missing IPSC_EMAIL_SENDER environment variable" return } if (-not $recipientEmailsStr) { Write-Log -Level WARN -Message "Email notifier disabled: missing IPSC_EMAIL_RECIPIENTS environment variable" return } # Get credentials from encrypted credential store if (-not $credStorePath) { $credStorePath = "$env:APPDATA\IPSC-Kurs-Watcher\credentials" } $clientSecret = _GetCredentialFromStore -StorePath $credStorePath if (-not $clientSecret) { Write-Log -Level ERROR -Message "Email notifier failed: could not load Client Secret from credential store at $credStorePath" return } # Get OAuth2 token $cacheDir = if ([System.IO.Path]::IsPathRooted($Config.token_cache_path)) { $Config.token_cache_path } else { $root = if ($script:ProjectRoot) { $script:ProjectRoot } else { (Get-Location).Path } Join-Path $root $Config.token_cache_path } $token = Get-AzureOAuthToken -TenantId $tenantId -ClientId $clientId ` -ClientSecret $clientSecret -CachePath $cacheDir -TimeoutSeconds $Config.timeout_seconds if (-not $token -or -not $token.access_token) { Write-Log -Level ERROR -Message "Email notifier failed: could not obtain OAuth2 token" return } # Build email $htmlBody = _BuildEmailBody -Alerts $Alerts # Create subject with proper UTF-8 encoding for special characters (ü=252, ä=228) $subject = "IPSC Kurs Watcher - Neue Kurse und Verf" + [char]252 + "gbarkeits" + [char]228 + "nderungen" # Parse recipient emails (comma-separated list supported) $recipients = @($recipientEmailsStr -split ',').Trim() | Where-Object { $_ } # Send email with retry logic (senderEmail is the mailbox account for sendMail) $sent = _SendMailViaGraph -AccessToken $token.access_token -UserId $senderEmail ` -Recipients $recipients -Subject $subject -HtmlBody $htmlBody ` -TimeoutSeconds $Config.timeout_seconds -MaxRetries $Config.retry_attempts if ($sent) { Write-Log -Level INFO -Message "Email notification sent" ` -Context @{ alert_count = $Alerts.Count; recipient_count = $recipients.Count; recipients = ($recipients -join ';') } } } catch { Write-Log -Level WARN -Message "Email notification failed" -Exception $_ } } |