PSWorkbench.psm1
|
#Region '.\Public\Get-ADBulkUserHashtable.ps1' -1 function Get-ADBulkUserHashtable { <# .SYNOPSIS Retrieves Active Directory user information for multiple users from primary and backup domains .DESCRIPTION Searches for multiple AD users simultaneously using an optimized LDAP filter with flexible search criteria. If users are not found in the default domain, performs a secondary search against a specified global catalog server. Returns user details in a hashtable for efficient lookups. Uses verbose output to report users not found in either domain. .PARAMETER UserList Array of user identifiers to search for in Active Directory. The identifier type is determined by the SearchBy parameter. .PARAMETER SearchBy Specifies which AD attribute to use for user lookups. Use 'Auto' (default) to detect per-value based on format: values containing '@' resolve to UserPrincipalName, 'CN=' prefix to DistinguishedName, all-digits to EmployeeID, whitespace to DisplayName, and everything else to SamAccountName. Mixed input lists are fully supported in Auto mode. Note: EmployeeID and Mail are not in the default Get-ADUser property set - include them via -Properties if needed. .PARAMETER Server Active Directory server to target for the primary search. When omitted, the default domain controller is used. .PARAMETER ADGlobalCatalog Global catalog server and port for backup domain searches when users are not found in the default domain. .PARAMETER Properties Optional array of additional AD properties to retrieve for each user .EXAMPLE Get-ADBulkUserHashtable -UserList 'user1', 'user2', 'user3' Searches for three users using Auto detection (all resolve to SamAccountName) .EXAMPLE Get-ADBulkUserHashtable -UserList 'user1@company.com', 'jdoe', 'CN=Jane Smith,OU=Users,DC=corp,DC=com' Mixed input list: UPN, SamAccountName, and DistinguishedName resolved automatically via Auto detection .EXAMPLE Get-ADBulkUserHashtable -UserList 'user1@company.com', 'user2@company.com' -SearchBy 'UserPrincipalName' -Verbose Explicit SearchBy - all values searched by UPN with verbose output for tracking search results .EXAMPLE Get-ADBulkUserHashtable -UserList 'John Doe', 'Jane Smith' -SearchBy 'DisplayName' -Properties 'Department', 'Title' Retrieves users by display name with additional properties .NOTES Author: https://github.com/dan-metzler PowerShellVersion: PowerShell 5.1 or Later Recommended. Features: Bulk user lookup, flexible search criteria, automatic failover to global catalog, hashtable return for efficient lookups, verbose logging #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string[]]$UserList, [Parameter()] [ValidateSet("Auto", "SamAccountName", "UserPrincipalName", "Mail", "DisplayName", "EmployeeID", "DistinguishedName")] [string]$SearchBy = "Auto", # Parameter help description [Parameter()] [string]$Server, # this parameter is when users are not found in the default domain, we check a backup domain, this is the server for the backup domain. [Parameter()] [string]$ADGlobalCatalog, # Optional properties parameter [Parameter()] [string[]]$Properties ) # Map parameter values to actual LDAP attribute names $ldapAttributeMap = @{ "SamAccountName" = "sAMAccountName" "UserPrincipalName" = "userPrincipalName" "Mail" = "mail" "DisplayName" = "displayName" "EmployeeID" = "employeeID" "DistinguishedName" = "distinguishedName" } # When SearchBy is Auto, detect each input value's attribute type by its distinct form: # ^\s*(?i:(CN|OU|DC|...)) -> DistinguishedName | ^[^@\s]+@[^\s]+$ -> UserPrincipalName # ^\d+$ -> EmployeeID | whitespace -> DisplayName | default -> SamAccountName if ($SearchBy -eq 'Auto') { $inputAttributeMap = @{} foreach ($inputValue in $UserList) { $inputAttributeMap[$inputValue] = switch -Regex ($inputValue) { '^\s*(?i:(CN|OU|DC|O|L|ST|C)=)' { 'DistinguishedName'; break } '^[^@\s]+@[^\s]+$' { 'UserPrincipalName'; break } '^\d+$' { 'EmployeeID'; break } '\s' { 'DisplayName'; break } default { 'SamAccountName' } } } } else { $ldapAttribute = $ldapAttributeMap[$SearchBy] } # DisplayName, EmployeeID, and Mail are not in the default Get-ADUser property set. # In Auto mode, auto-inject whichever of those were detected so the post-search matching # can read them back from the returned user objects. $effectiveProperties = $Properties if ($SearchBy -eq 'Auto') { $autoProps = ($inputAttributeMap.Values | Select-Object -Unique | Where-Object { $_ -in 'DisplayName', 'EmployeeID', 'Mail' }) if ($autoProps) { $effectiveProperties = @($autoProps) + @($Properties | Where-Object { $_ }) | Select-Object -Unique } Write-Verbose "Auto-detected attribute types: $(($inputAttributeMap.GetEnumerator() | ForEach-Object { "'$($_.Key)' -> '$($_.Value)'" }) -join ' | ')" } # Construct LDAP filter - in Auto mode each value uses its detected attribute; otherwise all share the same attribute $hashtable = @{} if ($SearchBy -eq 'Auto') { $ldapFilter = "(|" + ($UserList | ForEach-Object { "($($ldapAttributeMap[$inputAttributeMap[$_]])=$_)" }) + ")" } else { $ldapFilter = "(|" + ($UserList | ForEach-Object { "($ldapAttribute=$_)" }) + ")" } # Use splatting to conditionally include Properties parameter $getUserParams = @{ LDAPFilter = $ldapFilter } if ($effectiveProperties) { $getUserParams['Properties'] = $effectiveProperties } if ($Server) { $getUserParams['Server'] = $Server } $userDetailsList = @(Get-ADUser @getUserParams) # Create lookup mapping from search input to found users $searchInputToUser = @{} for ($i = 0; $i -lt $userDetailsList.Count; $i++) { $currentUser = $userDetailsList[$i] # Find which input value(s) match this user - in Auto mode each input value checks its own detected attribute $matchingInputs = $UserList | Where-Object { $attr = if ($SearchBy -eq 'Auto') { $inputAttributeMap[$_] } else { $SearchBy } $currentUser.$attr -eq $_ } foreach ($input in $matchingInputs) { $searchInputToUser[$input] = $currentUser } } # add the results to the hashtable keyed by SamAccountName for consistent lookups regardless of input form foreach ($inputValue in $UserList) { if ($searchInputToUser.ContainsKey($inputValue)) { $hashtable[$searchInputToUser[$inputValue].SamAccountName] = $searchInputToUser[$inputValue] } } if ($hashtable.Count -ne $UserList.Count) { # Use the input-tracking map (not the hashtable) to find which inputs didn't resolve to a user $notFoundUsers = $UserList | Where-Object { -not $searchInputToUser.ContainsKey($_) } if (-not $ADGlobalCatalog) { foreach ($user in $notFoundUsers) { Write-Warning "User not found in ($env:USERDNSDOMAIN): $user" } } else { Write-Verbose "$($notFoundUsers.Count) users were not found in Default Active Directory, searching global catalog..." # for users not found in the original search we need a sub search against the global catalog, we can reuse the same parameters but need to change the server and ldap filter if ($SearchBy -eq 'Auto') { $notFoundUsersLdapFilter = "(|" + ($notFoundUsers | ForEach-Object { "($($ldapAttributeMap[$inputAttributeMap[$_]])=$_)" }) + ")" } else { $notFoundUsersLdapFilter = "(|" + ($notFoundUsers | ForEach-Object { "($ldapAttribute=$_)" }) + ")" } $getUserParams['Server'] = $ADGlobalCatalog $getUserParams['LDAPFilter'] = $notFoundUsersLdapFilter $userBackupDetailsList = @(Get-ADUser @getUserParams) # add the backup results to the hashtable, nothing added if the userBackupDetailsList is empty $backupSearchInputToUser = @{} for ($i = 0; $i -lt $userBackupDetailsList.Count; $i++) { $currentUser = $userBackupDetailsList[$i] # Find which input value(s) match this user from backup search - same Auto-mode attribute detection $matchingInputs = $notFoundUsers | Where-Object { $attr = if ($SearchBy -eq 'Auto') { $inputAttributeMap[$_] } else { $SearchBy } $currentUser.$attr -eq $_ } foreach ($inputItem in $matchingInputs) { $backupSearchInputToUser[$inputItem] = $currentUser } } # add the backup results keyed by SamAccountName foreach ($inputValue in $notFoundUsers) { if ($backupSearchInputToUser.ContainsKey($inputValue)) { $hashtable[$backupSearchInputToUser[$inputValue].SamAccountName] = $backupSearchInputToUser[$inputValue] } } # Check if there are still users not found after backup search $stillNotFoundUsers = $notFoundUsers | Where-Object { -not $backupSearchInputToUser.ContainsKey($_) } if ($stillNotFoundUsers.Count -gt 0) { foreach ($user in $stillNotFoundUsers) { Write-Warning "User not found in ($env:USERDNSDOMAIN) or ($ADGlobalCatalog): $user" } } } } if ($hashtable.count -gt 0) { $hashtable } else { $null } } #EndRegion '.\Public\Get-ADBulkUserHashtable.ps1' 223 #Region '.\Public\Install-Certificate.ps1' -1 function Install-Certificate { <# .SYNOPSIS Writes a certificate file into a Windows certificate store. .DESCRIPTION Accepts a filesystem path or HTTPS URL pointing to a certificate file (PFX, CER, SST, P7B, or PEM) and writes it into the target Windows certificate store. The destination store location is resolved automatically from the execution context. Processes running as NT AUTHORITY\SYSTEM write to LocalMachine; all other contexts write to the CurrentUser store of the calling user. Certificate collections (PFX, P7B, SST) are always committed as a unit to a single store. To route individual certificates from a collection into different stores, split the collection upstream and invoke this function once per certificate. .PARAMETER CertificatePath Filesystem path or HTTPS URL to the source certificate file. Accepted extensions: .pfx .cer .sst .p7b .pem .PARAMETER CertificateStore Target store identified by its Windows-friendly name. Must be one of: Personal Trusted Root Certification Authorities Third-Party Root Certification Authorities Trusted Publisher Intermediate Certification Authorities Untrusted Certificates Trusted People Other People .PARAMETER CertificatePassword SecureString password required to open the certificate file, if one is set. .PARAMETER OverwriteExisting Before writing, remove any certificate already present in the target store whose thumbprint matches the incoming certificate. Without this switch, a matching thumbprint causes the function to return without making any changes. .EXAMPLE $pw = ConvertTo-SecureString 'P@ssw0rd' -AsPlainText -Force Install-Certificate -CertificatePath 'C:\Certs\internal-ca.pfx' -CertificateStore 'Personal' -CertificatePassword $pw .EXAMPLE Install-Certificate -CertificatePath 'C:\Certs\root-ca.cer' -CertificateStore 'Trusted Root Certification Authorities' -OverwriteExisting .NOTES Execution context determines the store location written to: NT AUTHORITY\SYSTEM -> LocalMachine All other users -> CurrentUser #> [CmdletBinding()] param ( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$CertificatePath, [Parameter(Mandatory)] [ValidateSet( "Personal", "Trusted Root Certification Authorities", "Third-Party Root Certification Authorities", "Trusted Publisher", "Intermediate Certification Authorities", "Untrusted Certificates", "Trusted People", "Other People" )] [string]$CertificateStore, [Parameter()] [SecureString]$CertificatePassword, [Parameter()] [switch]$OverwriteExisting ) begin { function Test-IsSystemAccount { $currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent() return $currentIdentity.Name -like "NT AUTHORITY*" -or $currentIdentity.IsSystem } function Invoke-CertificateDownload { param ( [Parameter(Mandatory)] [string]$SourceUrl, [Parameter(Mandatory)] [string]$DestinationPath, [Parameter()] [int]$MaxAttempts = 3, [Parameter()] [switch]$SkipDelay ) Write-Verbose "Downloading certificate from '$SourceUrl'" $supportedTls = [enum]::GetValues('Net.SecurityProtocolType') if (($supportedTls -contains 'Tls13') -and ($supportedTls -contains 'Tls12')) { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls13 -bor [System.Net.SecurityProtocolType]::Tls12 } elseif ($supportedTls -contains 'Tls12') { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 } else { Write-Warning "TLS 1.2 and TLS 1.3 are not supported on this system. The download may fail." } $attempt = 1 while ($attempt -le $MaxAttempts) { if (-not $SkipDelay) { $delaySecs = Get-Random -Minimum 3 -Maximum 15 Write-Verbose "Waiting $delaySecs seconds before attempt $attempt." Start-Sleep -Seconds $delaySecs } Write-Verbose "Download attempt $attempt of $MaxAttempts" $previousProgress = $ProgressPreference $ProgressPreference = 'SilentlyContinue' try { if ($PSVersionTable.PSVersion.Major -lt 4) { $webClient = [System.Net.WebClient]::new() $webClient.DownloadFile($SourceUrl, $DestinationPath) } else { Invoke-WebRequest -Uri $SourceUrl -OutFile $DestinationPath -MaximumRedirection 10 -UseBasicParsing } $fileExists = Test-Path -Path $DestinationPath -ErrorAction SilentlyContinue } catch { Write-Warning "Download attempt $attempt failed: $($_.Exception.Message)" if (Test-Path -Path $DestinationPath -ErrorAction SilentlyContinue) { Remove-Item -Path $DestinationPath -Force -Confirm:$false -ErrorAction SilentlyContinue } $fileExists = $false } $ProgressPreference = $previousProgress if ($fileExists) { $attempt = $MaxAttempts } else { Write-Warning "Download attempt $attempt did not produce a file." } $attempt++ } if (-not (Test-Path $DestinationPath)) { throw "Failed to download certificate from '$SourceUrl'. Verify the URL is accessible and the certificate exists at that location." } return $DestinationPath } function ConvertTo-PlainText { # Converts a SecureString to a plain-text string via BSTR, then immediately zeroes the BSTR. # The returned string lives in managed memory and cannot be zeroed, so keep its lifetime as short as possible. param ( [Parameter(Mandatory)] [SecureString]$SecureValue ) $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureValue) try { return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } # Map the friendly store name to the .NET StoreName enum value $storeName = switch ($CertificateStore) { "Personal" { [System.Security.Cryptography.X509Certificates.StoreName]::My } "Trusted Root Certification Authorities" { [System.Security.Cryptography.X509Certificates.StoreName]::Root } "Third-Party Root Certification Authorities" { [System.Security.Cryptography.X509Certificates.StoreName]::AuthRoot } "Trusted Publisher" { [System.Security.Cryptography.X509Certificates.StoreName]::TrustedPublisher } "Intermediate Certification Authorities" { [System.Security.Cryptography.X509Certificates.StoreName]::CertificateAuthority } "Untrusted Certificates" { [System.Security.Cryptography.X509Certificates.StoreName]::Disallowed } "Trusted People" { [System.Security.Cryptography.X509Certificates.StoreName]::TrustedPeople } "Other People" { [System.Security.Cryptography.X509Certificates.StoreName]::AddressBook } } } process { # Stage the certificate to a unique temp path to avoid file collisions $certGuid = [System.Guid]::NewGuid().Guid $certTempPath = "$env:TEMP\cert_$certGuid" if ($CertificatePath -like "*.*") { $fileExtension = $CertificatePath.Split(".")[-1].ToLower() switch ($fileExtension) { "pfx" { $certTempPath += ".pfx" } "cer" { $certTempPath += ".cer" } "sst" { $certTempPath += ".sst" } "p7b" { $certTempPath += ".p7b" } "pem" { $certTempPath += ".pem" } default { throw "Unsupported certificate file extension '.$fileExtension'. Supported formats: pfx, cer, pem, sst, p7b." } } } else { throw "Certificate path '$CertificatePath' has no file extension. Supported formats: pfx, cer, pem, sst, p7b." } # Obtain the certificate file - download if a URL was provided, otherwise copy locally if ($CertificatePath -like "http*") { if ($CertificatePath -notmatch "^https?://") { throw "Invalid URL format: '$CertificatePath'" } if ($CertificatePath -match "^http://") { Write-Warning "Certificate is being downloaded over an unencrypted HTTP connection." } Invoke-CertificateDownload -SourceUrl $CertificatePath -DestinationPath $certTempPath } else { if (-not (Test-Path $CertificatePath)) { throw "Certificate file not found at path '$CertificatePath'." } try { Copy-Item -Path (Resolve-Path -Path $CertificatePath) -Destination $certTempPath -Force } catch { throw "Failed to copy certificate from '$CertificatePath': $($_.Exception.Message)" } } # Load the certificate into a .NET object $certObject = $null $importFailed = $false try { Write-Verbose "Detecting certificate content type" $certContentType = [System.Security.Cryptography.X509Certificates.X509Certificate2]::GetCertContentType($certTempPath) Write-Verbose "Certificate content type: $certContentType" $keyFlags = if (Test-IsSystemAccount) { [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::MachineKeySet -bor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet } else { [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::UserKeySet -bor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::PersistKeySet } switch ($certContentType) { "Unknown" { throw "Certificate file is empty or unreadable: '$certTempPath'" } { $_ -in @("Cert", "SerializedCert") } { Write-Verbose "Loading $certContentType certificate" $certObject = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certTempPath) Write-Verbose "Certificate loaded" } { $_ -in @("Pfx", "Pkcs12", "SerializedStore", "Pkcs7", "Authenticode") } { Write-Verbose "Loading $certContentType certificate collection" $certCollection = [System.Security.Cryptography.X509Certificates.X509Certificate2Collection]::new() if ($null -ne $CertificatePassword -and $CertificatePassword.Length -gt 0) { # X509Certificate2Collection.Import has no SecureString overload in .NET Framework 4.x; # convert via BSTR and zero it immediately after the call. $plainPassword = ConvertTo-PlainText -SecureValue $CertificatePassword try { $certCollection.Import($certTempPath, $plainPassword, $keyFlags) } finally { $plainPassword = $null } } else { $certCollection.Import($certTempPath) } $certObject = $certCollection Write-Verbose "Certificate collection loaded ($($certCollection.Count) certificate(s))" } default { throw "Unrecognised certificate content type: $certContentType" } } } catch { $importFailed = $true } # Fall back to direct password-based construction if the initial load failed if ($importFailed) { try { # X509Certificate2 has a native SecureString constructor - no plain-text conversion needed here. $certObject = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certTempPath, $CertificatePassword) } catch { Remove-Item -Path $certTempPath -Force -ErrorAction SilentlyContinue switch -Regex ($_.Exception.Message) { "Cannot find the original signer" { throw "Certificate load failed: cannot find the original signer." } default { throw "Failed to load certificate from '$CertificatePath': $($_.Exception.Message)" } } } } # Open the target certificate store $storeLocation = if (Test-IsSystemAccount) { [System.Security.Cryptography.X509Certificates.StoreLocation]::LocalMachine } else { [System.Security.Cryptography.X509Certificates.StoreLocation]::CurrentUser } try { $certStore = [System.Security.Cryptography.X509Certificates.X509Store]::new($storeName, $storeLocation) } catch { throw "Failed to create certificate store object for '$CertificateStore': $($_.Exception.Message)" } try { $certStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::MaxAllowed) Write-Verbose "Opened certificate store '$CertificateStore' ($storeLocation) with read/write access" } catch { throw "Failed to open certificate store '$CertificateStore': $($_.Exception.Message)" } # Flatten the cert object to a consistent list for thumbprint checks $certsToInstall = if ( $certObject -is [System.Security.Cryptography.X509Certificates.X509Certificate2Collection] -or $certObject -is [System.Object[]] ) { $certObject } elseif ($certObject -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) { @($certObject) } else { $certStore.Close() Remove-Item -Path $certTempPath -Force -ErrorAction SilentlyContinue throw "Unexpected certificate object type: $($certObject.GetType().FullName)" } # Check for existing installations and handle overwrite if requested foreach ($cert in $certsToInstall) { if ($certStore.Certificates.Thumbprint -contains $cert.Thumbprint) { if ($OverwriteExisting) { $certStore.Certificates | Where-Object { $_.Thumbprint -eq $cert.Thumbprint } | ForEach-Object { try { Write-Verbose "Removing existing certificate: $($_.FriendlyName) (Thumbprint: $($_.Thumbprint))" $certStore.Remove($_) Write-Verbose "Existing certificate removed" } catch { $certStore.Close() throw "Failed to remove existing certificate '$($_.FriendlyName)': $($_.Exception.Message)" } } } else { Write-Verbose "Certificate already present in store (Thumbprint: $($cert.Thumbprint)). Specify -OverwriteExisting to replace it." $certStore.Close() Remove-Item -Path $certTempPath -Force -ErrorAction SilentlyContinue return } } } # Install certificates into the store try { if ($certObject -is [System.Security.Cryptography.X509Certificates.X509Certificate2Collection]) { $certStore.AddRange($certObject) $certObject | ForEach-Object { Write-Verbose "Installed: $($_.FriendlyName) (Thumbprint: $($_.Thumbprint))" } } elseif ($certObject -is [System.Object[]]) { $certObject | ForEach-Object { $certStore.Add($_) Write-Verbose "Installed: $($_.FriendlyName) (Thumbprint: $($_.Thumbprint))" } } else { $certStore.Add($certObject) Write-Verbose "Installed: $($certObject.FriendlyName) (Thumbprint: $($certObject.Thumbprint))" } } catch { $certStore.Close() throw "Failed to install certificate into '$CertificateStore': $($_.Exception.Message)" } try { $certStore.Close() Write-Verbose "Certificate store '$CertificateStore' closed" } catch { Write-Warning "Failed to close certificate store '$CertificateStore': $($_.Exception.Message)" } Remove-Item -Path $certTempPath -Force -ErrorAction SilentlyContinue Write-Verbose "Certificate installation complete" } end {} } #EndRegion '.\Public\Install-Certificate.ps1' 411 #Region '.\Public\New-HTMLEmailBody.ps1' -1 function New-HTMLEmailBody { <# .SYNOPSIS Generates an HTML email body with token replacement, theming, and optional table injection. .DESCRIPTION Builds a fully rendered HTML email string from an embedded template. Replaces content tokens (e.g., {{MAIN_HEADER}}, {{PARAGRAPH_INTRO}}) with parameter values, applies theme colors from a customizable hashtable, and optionally injects an HTML table from a PSObject array. Standard text is HTML encoded; BodyHtml is reserved for trusted, internally composed HTML fragments. Validates the logo image via Test-Base64Image and gracefully removes it if invalid. Returns the final HTML string for use with Send-MailMessage or similar cmdlets. .PARAMETER Base64ImageString Optional Base64-encoded image string (with or without data URI prefix) used as the email logo. .PARAMETER MainHeader Text replacing the {{MAIN_HEADER}} token. Displayed as the email heading. .PARAMETER PreviewText Text replacing the {{PREVIEW_TEXT}} token. Used for email client preview snippets. .PARAMETER TeamSignOff Team name or sign-off text replacing the {{TEAM_SIGN_OFF}} token. .PARAMETER ParagraphIntro Introductory paragraph text replacing the {{PARAGRAPH_INTRO}} token. .PARAMETER BodyHtml Trusted HTML inserted below the introductory paragraph. Use only for internally composed content, such as links, notices, or reusable call-to-action markup. Values are not HTML encoded and inherit 14px Calibri styling unless the supplied HTML defines its own style. .PARAMETER ActionLinks Optional collection of hashtables that render as standard call-to-action links. Each hashtable requires Label and Url keys. Color is optional and overrides the theme primary color for that link. The email-compatible markup and remaining appearance are owned by this function. .PARAMETER Admonitions Optional collection of importance callouts. Each hashtable requires Type and either Message or MessageHtml. Type must be Note, Tip, Info, Warning, or Danger. Title and Placement are optional; Placement is Body or AfterTable and defaults to AfterTable. Message and Title are HTML encoded. MessageHtml is inserted as trusted, internally authored HTML. .PARAMETER FooterText Footer content replacing the {{FOOTER_TEXT}} token. .PARAMETER EmailFilterText Filter tag text replacing the {{EMAIL_FILTER_TEXT}} token, used for mailbox rule filtering. .PARAMETER Table Switch to enable HTML table generation and injection into the {{TABLE}} placeholder. .PARAMETER TableObject An array of PSObjects whose properties are rendered as key-value rows in the HTML table. Required when -Table is specified. .PARAMETER TableLayout Controls how the table is rendered. Defaults to RowHeaders. RowHeaders - property names appear as row headers on the left; one row per property per object. ColumnHeaders - property names appear as column headers across the top; one row per object. .PARAMETER Theme Hashtable of color overrides. Valid keys: COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_BG_BODY, COLOR_BG_CONTENT, COLOR_BORDER_MAIN, COLOR_TEXT, COLOR_TEXT_WHITE, COLOR_FOOTER_TEXT, COLOR_FILTER_TEXT. .EXAMPLE New-HTMLEmailBody -Base64ImageString $logo -MainHeader "Request Completed" -PreviewText "Details inside." -TeamSignOff "AD Team" -ParagraphIntro "Your request has been processed." -FooterText "Internal Use Only" -EmailFilterText "[Filter]" .EXAMPLE $items = @([PSCustomObject]@{ "Account" = "us-svc-test" }) New-HTMLEmailBody -Base64ImageString $logo -MainHeader "DIL Removal" -PreviewText "Summary" -TeamSignOff "AD Team" -ParagraphIntro "Accounts removed." -FooterText "Internal Use Only" -EmailFilterText "[Filter]" -Table -TableObject $items -Theme @{ COLOR_PRIMARY = '#FF0000' } .NOTES PowerShellVersion: PowerShell 5.1 or Later Recommended. #> [CmdletBinding(DefaultParameterSetName = 'NoTable')] [OutputType([string])] param ( [Parameter()] [string]$Base64ImageString, [Parameter(Mandatory)] [string]$MainHeader, [Parameter(Mandatory)] [string]$PreviewText, [Parameter(Mandatory)] [string]$TeamSignOff, [Parameter(Mandatory)] [string]$ParagraphIntro, [Parameter()] [string]$BodyHtml, [Parameter()] [hashtable[]]$ActionLinks = @(), [Parameter()] [hashtable[]]$Admonitions = @(), [Parameter(Mandatory)] [string]$FooterText, [Parameter(Mandatory)] [string]$EmailFilterText, [Parameter(ParameterSetName = 'WithTable')] [switch]$Table, [Parameter(Mandatory, ParameterSetName = 'WithTable')] [psobject[]]$TableObject, [Parameter(ParameterSetName = 'WithTable')] [ValidateSet('RowHeaders', 'ColumnHeaders')] [string]$TableLayout = 'RowHeaders', [Parameter(HelpMessage = "Hashtable of theme colors to override defaults. Keys: COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_BG_BODY, COLOR_BG_CONTENT, COLOR_BORDER_MAIN, COLOR_TEXT, COLOR_TEXT_WHITE, COLOR_FOOTER_TEXT, COLOR_FILTER_TEXT")] [hashtable]$Theme = @{} ) begin { $validLogo = $false if (-not [string]::IsNullOrWhiteSpace($Base64ImageString)) { $logoValidator = Get-Command -Name Test-Base64Image -CommandType Function -ErrorAction SilentlyContinue if ($logoValidator) { $validLogo = Test-Base64Image -Base64String $Base64ImageString if (-not $validLogo) { Write-Warning "Provided Base64String is not a valid image." } } else { Write-Warning "Test-Base64Image is unavailable. The email logo will be omitted." } } # Default theme - override any key via the -Theme parameter $defaultTheme = @{ COLOR_PRIMARY = '#005EB8' # Headings, table header background COLOR_PRIMARY_DARK = '#00338d' # Table borders, table cell text COLOR_BG_BODY = '#f4f5f6' # Page / outer background COLOR_BG_CONTENT = '#ffffff' # Main content & table background COLOR_BORDER_MAIN = '#eaebed' # Main content card border COLOR_TEXT = '#000000' # Body text, divider COLOR_TEXT_WHITE = '#ffffff' # Text on primary backgrounds COLOR_FOOTER_TEXT = '#7F7F7F' # Footer paragraph text COLOR_FILTER_TEXT = '#f6f6f6' # Near-invisible email filter tag } # Merge user overrides into defaults foreach ($key in $Theme.Keys) { if ($defaultTheme.ContainsKey($key)) { $defaultTheme[$key] = $Theme[$key] } else { Write-Warning "Unknown theme key: '$key'. Valid keys: $($defaultTheme.Keys -join ', ')" } } # Embedded HTML email template $htmlTemplate = @' <!doctype html> <html lang="en"> <head> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>Simple Transactional Email</title> <style media="all" type="text/css"> /* ====================================== THEME COLORS - All {{COLOR_*}} tokens are replaced at runtime by PowerShell. ====================================== */ @media only screen and (max-width: 640px) { .main p, .main td, .main span { font-size: 16px !important; } .wrapper { padding: 8px !important; } .content { padding: 0 !important; } .container { padding: 0 !important; padding-top: 8px !important; width: 100% !important; } .main { border-left-width: 0 !important; border-radius: 0 !important; border-right-width: 0 !important; } .btn table { max-width: 100% !important; width: 100% !important; } .btn a { font-size: 16px !important; max-width: 100% !important; width: 100% !important; } } @media all { .ExternalClass { width: 100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } .apple-link a { color: inherit !important; font-family: inherit !important; font-size: inherit !important; font-weight: inherit !important; line-height: inherit !important; text-decoration: none !important; } #MessageViewBody a { color: inherit; text-decoration: none; font-size: inherit; font-family: inherit; font-weight: inherit; line-height: inherit; } } table.GeneratedTable { font-family: "Calibri", sans-serif; font-size: .9rem; width: 100%; background-color: {{COLOR_BG_CONTENT}}; border-collapse: collapse; border-width: 2px; border-color: {{COLOR_PRIMARY_DARK}}; border-style: solid; color: {{COLOR_TEXT}}; } table.GeneratedTable th{ border-collapse: collapse; border-width: 2px; border-color: {{COLOR_PRIMARY_DARK}}; background-color: {{COLOR_PRIMARY}}; border-style: solid; color: {{COLOR_TEXT_WHITE}}; padding: 3px 10px; width: 1%; white-space: nowrap; text-align: right; } table.GeneratedTable td { font-weight: bold; border-width: 2px; border-color: {{COLOR_PRIMARY_DARK}}; color: {{COLOR_PRIMARY_DARK}}; border-style: solid; padding: 3px; width: auto; } </style> </head> <body style="font-family: Calibri, sans-serif; -webkit-font-smoothing: antialiased; font-size: 16px; line-height: 1.3; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; background-color: {{COLOR_BG_BODY}}; margin: 0; padding: 0;"> <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: {{COLOR_BG_BODY}}; width: 100%;" width="100%"> <tr> <td style="font-family: Calibri, sans-serif; font-size: 16px; vertical-align: top;" valign="top"> </td> <td class="container" style="font-family: Calibri, sans-serif; font-size: 16px; vertical-align: top; max-width: 600px; padding: 0; padding-top: 24px; width: 600px; margin: 0 auto;" width="600" valign="top"> <div class="content" style="box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px; padding: 0;"> <!-- START CENTERED WHITE CONTAINER --> <span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">{{PREVIEW_TEXT}}</span> <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: {{COLOR_BG_CONTENT}}; border: 1px solid {{COLOR_BORDER_MAIN}}; width: 100%;" width="100%"> <!-- START MAIN CONTENT AREA --> <tr> <td class="wrapper" style="font-family: Calibri, sans-serif; font-size: 16px; vertical-align: top; box-sizing: border-box; padding: 24px;" valign="top"> <img height=37 src="{{BASE_64_LOGO}}"> <br><br> <p style="font-family: Arial, Helvetica, sans-serif, sans-serif; font-size: 18px; font-weight: normal; margin: 0; margin-bottom: 16px; font-weight: bold; color: {{COLOR_PRIMARY}};">{{MAIN_HEADER}}</p> <p style="font-family: Calibri, sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 16px;">Hello,</p> <p style="font-family: Calibri, sans-serif; font-size: 14px; font-weight: normal; margin: 0; margin-bottom: 16px;">{{PARAGRAPH_INTRO}}</p> {{BODY_HTML}} {{BODY_ADMONITIONS}} <table class=MsoNormalTable border=0 cellspacing=0 cellpadding=0 width=100%> <tr> <td width=100% valign=top style="border-bottom:solid #d0d0d0 1.0pt;"> </td> </tr> </table> <br> {{TABLE}} {{ADMONITIONS}} {{BUTTONS}} <br> <p style="font-family: Calibri, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;">Thank you,</p> <p style="font-family: Calibri, sans-serif; font-size: 16px; font-weight: normal; margin: 0; margin-bottom: 16px;"><b>{{TEAM_SIGN_OFF}}</b></p> </td> </tr> <!-- END MAIN CONTENT AREA --> </table> <!-- START FOOTER --> <div class="footer" style="clear: both; padding-top: 24px; text-align: center; width: 100%;"> <table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;" width="100%"> <tr> <td class="content-block" style="font-family: Calibri, sans-serif; vertical-align: top; color: {{COLOR_FOOTER_TEXT}}; font-size: 10px; text-align: center;" valign="top" align="center"> <br> {{FOOTER_TEXT}} </td> </tr> <tr> <td class="content-block powered-by" style="font-family: Calibri, sans-serif; vertical-align: top; color: {{COLOR_FOOTER_TEXT}}; font-size: 16px; text-align: center;" valign="top" align="center"> <span style="color: {{COLOR_FILTER_TEXT}}; font-size: 8px; text-align: center;">{{EMAIL_FILTER_TEXT}}</span> </td> </tr> </table> </div> <!-- END FOOTER --> <!-- END CENTERED WHITE CONTAINER --></div> </td> <td style="font-family: Calibri, sans-serif; font-size: 16px; vertical-align: top;" valign="top"> </td> </tr> </table> </body> </html> '@ } process { $htmlContent = $htmlTemplate # Replace color tokens with theme values foreach ($key in $defaultTheme.Keys) { $htmlContent = $htmlContent.Replace("{{$key}}", [string]$defaultTheme[$key]) } # Replace or remove logo based on validation if ($validLogo) { $htmlContent = $htmlContent.Replace('{{BASE_64_LOGO}}', $Base64ImageString) } else { $htmlContent = $htmlContent -replace '<img height=37 src="\{\{BASE_64_LOGO\}\}">\s*<br><br>', '' } $htmlContent = $htmlContent.Replace('{{MAIN_HEADER}}', [System.Net.WebUtility]::HtmlEncode($MainHeader)) $htmlContent = $htmlContent.Replace('{{PARAGRAPH_INTRO}}', [System.Net.WebUtility]::HtmlEncode($ParagraphIntro)) $renderedBodyHtml = if ([string]::IsNullOrWhiteSpace($BodyHtml)) { '' } else { '<div style="font-family: Calibri, sans-serif; font-size: 14px; line-height: 20px;">{0}</div>' -f $BodyHtml } $htmlContent = $htmlContent.Replace('{{BODY_HTML}}', $renderedBodyHtml) $htmlContent = $htmlContent.Replace('{{PREVIEW_TEXT}}', [System.Net.WebUtility]::HtmlEncode($PreviewText)) $htmlContent = $htmlContent.Replace('{{TEAM_SIGN_OFF}}', [System.Net.WebUtility]::HtmlEncode($TeamSignOff)) $htmlContent = $htmlContent.Replace('{{FOOTER_TEXT}}', [System.Net.WebUtility]::HtmlEncode($FooterText)) $htmlContent = $htmlContent.Replace('{{EMAIL_FILTER_TEXT}}', [System.Net.WebUtility]::HtmlEncode($EmailFilterText)) if ($Table) { if ($TableLayout -eq 'ColumnHeaders') { $headerCells = foreach ($headerName in $TableObject[0].PSObject.Properties.Name) { "<th align=`"center`" style=`"text-align: center; width: auto; white-space: normal;`">$([System.Net.WebUtility]::HtmlEncode([string]$headerName))</th>" } $headerRow = ' <tr>' + ($headerCells -join '') + '</tr>' $dataRows = foreach ($obj in $TableObject) { $dataCells = foreach ($prop in $obj.PSObject.Properties) { "<td align=`"center`" style=`"text-align: center;`">$([System.Net.WebUtility]::HtmlEncode([string]$prop.Value))</td>" } ' <tr>' + ($dataCells -join '') + '</tr>' } $tableHtml = @" <table class="GeneratedTable"> <thead> $headerRow </thead> <tbody> $($dataRows -join "`n") </tbody> </table> "@ } else { $rows = foreach ($obj in $TableObject) { foreach ($prop in $obj.PSObject.Properties) { $propertyName = [System.Net.WebUtility]::HtmlEncode($prop.Name) $propertyValue = [System.Net.WebUtility]::HtmlEncode([string]$prop.Value) " <tr>`n <th style=`"text-align: right;`">$propertyName : </th>`n <td style=`"text-align: left;`">$propertyValue</td>`n </tr>" } } $tableHtml = @" <table class="GeneratedTable"> <thead> $($rows -join "`n") </thead> </table> "@ } $htmlContent = $htmlContent.Replace('{{TABLE}}', $tableHtml) } else { $htmlContent = $htmlContent.Replace('{{TABLE}}', '') } if ($Admonitions.Count -gt 0) { $admonitionColors = @{ Note = @{ Border = '#448aff'; Background = '#edf4ff' } Tip = @{ Border = '#00a651'; Background = '#e8f7ee' } Info = @{ Border = '#0091da'; Background = '#e8f6fc' } Warning = @{ Border = '#d97706'; Background = '#fff5e6' } Danger = @{ Border = '#d92d20'; Background = '#fdecea' } } $bodyAdmonitions = [System.Collections.Generic.List[string]]::New() $afterTableAdmonitions = [System.Collections.Generic.List[string]]::New() $admonitionSpacer = '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse;"><tr><td height="16" style="height: 16px; font-size: 16px; line-height: 16px;"> </td></tr></table>' foreach ($admonition in $Admonitions) { if (-not $admonition.ContainsKey('Type')) { throw "Each admonition must include Type and either Message or MessageHtml keys." } if ($admonition.ContainsKey('Message') -eq $admonition.ContainsKey('MessageHtml')) { throw "Each admonition must include exactly one of Message or MessageHtml." } $type = [string]$admonition.Type if (-not $admonitionColors.ContainsKey($type)) { throw "Unsupported admonition type [$type]. Use Note, Tip, Info, Warning, or Danger." } $title = if ($admonition.ContainsKey('Title')) { [System.Net.WebUtility]::HtmlEncode([string]$admonition.Title) } else { $type } $message = if ($admonition.ContainsKey('MessageHtml')) { [string]$admonition.MessageHtml } else { [System.Net.WebUtility]::HtmlEncode([string]$admonition.Message) } $placement = if ($admonition.ContainsKey('Placement')) { [string]$admonition.Placement } else { 'AfterTable' } if ($placement -notin @('Body', 'AfterTable')) { throw "Unsupported admonition placement [$placement]. Use Body or AfterTable." } $colors = $admonitionColors[$type] $renderedAdmonition = '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; margin-top: 16px; background-color: {0}; border-left: 4px solid {1};"><tr><td valign="top" style="padding: 6px 14px; vertical-align: top;"><table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; width: 100%;"><tr><td valign="top" style="padding: 0; font-family: Calibri, sans-serif; color: {1}; font-size: 12px; font-weight: bold; line-height: 14px; mso-line-height-rule: exactly; vertical-align: top;">{2}</td></tr><tr><td height="4" style="height: 4px; font-size: 4px; line-height: 4px;"> </td></tr><tr><td valign="top" style="padding: 0; font-family: Calibri, sans-serif; color: #000000; font-size: 12px; line-height: 14px; mso-line-height-rule: exactly; vertical-align: top;">{3}</td></tr></table></td></tr></table>' -f $colors.Background, $colors.Border, $title, $message if ($placement -eq 'Body') { $renderedAdmonition = $renderedAdmonition.Replace('margin-top: 16px;', 'margin-top: 16px; margin-bottom: 16px;') $bodyAdmonitions.Add($renderedAdmonition + "`n" + $admonitionSpacer) } else { $afterTableAdmonitions.Add($renderedAdmonition) } } $htmlContent = $htmlContent.Replace('{{BODY_ADMONITIONS}}', ($bodyAdmonitions -join "`n")) $afterTableAdmonitionsHtml = if ($afterTableAdmonitions.Count -gt 0) { $trailingSpacer = if ($ActionLinks.Count -gt 0) { '' } else { "`n" + $admonitionSpacer } $admonitionSpacer + "`n" + ($afterTableAdmonitions -join ("`n" + $admonitionSpacer + "`n")) + $trailingSpacer } else { '' } $htmlContent = $htmlContent.Replace('{{ADMONITIONS}}', $afterTableAdmonitionsHtml) } else { $htmlContent = $htmlContent.Replace('{{BODY_ADMONITIONS}}', '') $htmlContent = $htmlContent.Replace('{{ADMONITIONS}}', '') } if ($ActionLinks.Count -gt 0) { $renderedActionLinks = @(foreach ($actionLink in $ActionLinks) { if (-not $actionLink.ContainsKey('Label') -or -not $actionLink.ContainsKey('Url')) { throw "Each action link must include Label and Url keys." } $label = [System.Net.WebUtility]::HtmlEncode([string]$actionLink.Label) $url = [System.Net.WebUtility]::HtmlEncode([string]$actionLink.Url) $backgroundColor = if ($actionLink.ContainsKey('Color')) { [System.Net.WebUtility]::HtmlEncode([string]$actionLink.Color) } else { $defaultTheme.COLOR_PRIMARY } '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse; width: 100%;"><tr><td align="center" bgcolor="{1}" height="46" style="height: 46px; background-color: {1}; border: 1px solid {1}; text-align: center; vertical-align: middle;"><a href="{0}" style="font-family: Calibri, sans-serif; font-size: 15px; color: {2}; text-decoration: none; text-align: center; font-weight: bold;" target="_blank">{3}</a></td></tr></table>' -f $url, $backgroundColor, $defaultTheme.COLOR_TEXT_WHITE, $label }) $actionLinkRows = [System.Collections.Generic.List[string]]::New() for ($index = 0; $index -lt $renderedActionLinks.Count; $index += 2) { $firstLink = $renderedActionLinks[$index] $hasSecondLink = $index + 1 -lt $renderedActionLinks.Count if ($hasSecondLink) { $secondLink = $renderedActionLinks[$index + 1] $actionLinkRows.Add((' <tr><td width="50%" style="width: 50%; padding: 0 5px 10px 0;">{0}</td><td width="50%" style="width: 50%; padding: 0 0 10px 5px;">{1}</td></tr>' -f $firstLink, $secondLink)) } else { $actionLinkRows.Add((' <tr><td colspan="2" width="100%" style="width: 100%; padding: 0 0 10px;">{0}</td></tr>' -f $firstLink)) } } $actionLinksHtml = ' <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse;"><tr><td height="16" style="height: 16px; font-size: 16px; line-height: 16px;"> </td></tr></table>' + "`n" + ' <table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; width: 100%;">' + "`n" + ' <tbody>' + "`n" + ($actionLinkRows -join "`n") + "`n" + ' </tbody>' + "`n" + ' </table>' $htmlContent = $htmlContent.Replace('{{BUTTONS}}', $actionLinksHtml) } else { $htmlContent = $htmlContent.Replace('{{BUTTONS}}', '') } Write-Output $htmlContent } end { } } #EndRegion '.\Public\New-HTMLEmailBody.ps1' 553 #Region '.\Public\Resolve-ADGroupMember.ps1' -1 function Resolve-ADGroupMember { <# .SYNOPSIS Retrieves Active Directory group members with cross-domain resolution capabilities to handle complex multi-domain environments. .DESCRIPTION This function addresses common challenges in multi-domain Active Directory environments by retrieving group members and automatically handling cross-domain member resolution. It queries the specified Active Directory groups for their membership details, attempts to resolve each member object in the local domain first, and falls back to global catalog queries when members exist in different domains. The function provides robust error handling for orphaned or inaccessible member references and returns detailed member object information for analysis and reporting. .PARAMETER Identity Array of Active Directory group identities (names, distinguished names, or SIDs) for which to retrieve membership information. Supports pipeline input and processes multiple groups efficiently with cross-domain member resolution. .PARAMETER ADGlobalCatalog Global catalog server for cross-domain member resolution. Used as a fallback when a member object cannot be found in the local domain. .EXAMPLE Resolve-ADGroupMember -Identity "Domain-SecurityGroup-Name" Retrieves all members of the specified security group, resolving members across domains as needed. .EXAMPLE "Group1", "Group2", "Group3" | Resolve-ADGroupMember Uses pipeline input to process multiple groups and return comprehensive member information with cross-domain resolution. .NOTES Author: https://github.com/dan-metzler PowerShellVersion: PowerShell 5.1 or Later Recommended. Features: Cross-domain resolution, Global catalog queries, Pipeline support, Error handling, Multi-group processing #> [CmdletBinding()] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Position = 0 )] [string[]]$Identity, # Active Directory global catalog server for cross-domain member resolution when local queries fail [Parameter(Mandatory)] [string]$ADGlobalCatalog ) foreach ($GroupIdentity in $Identity) { $Group = $null $Group = Get-ADGroup -Identity $GroupIdentity -Properties Member if (-not $Group) { continue } Foreach ($Member in $Group.Member) { try { Get-ADObject $Member } catch { if ($_.Exception.GetType().FullName -eq 'Microsoft.ActiveDirectory.Management.ADIdentityNotFoundException') { Get-ADObject $Member -Server $ADGlobalCatalog } else { Write-Error "Error finding $Member in Root Global Domains" } } } } } #EndRegion '.\Public\Resolve-ADGroupMember.ps1' 73 #Region '.\Public\Test-Base64Image.ps1' -1 function Test-Base64Image { <# .SYNOPSIS Tests whether a Base64-encoded string represents a valid image. .DESCRIPTION Decodes a Base64 string and attempts to load it as a System.Drawing.Image. Returns $true if the string decodes to a valid image, $false otherwise. Accepts bare Base64 strings as well as data URI strings with the prefix "data:image/<type>;base64," — the prefix is stripped automatically before decoding. .PARAMETER Base64String The Base64-encoded string to validate. Accepts a bare Base64 value or a full data URI (e.g., "data:image/png;base64,<data>"). .EXAMPLE Test-Base64Image -Base64String $encodedString Returns $true if $encodedString decodes to a valid image, $false otherwise. .EXAMPLE Test-Base64Image -Base64String "data:image/png;base64,iVBORw0KGgo..." Strips the data URI prefix and validates the embedded Base64 image data. .INPUTS None. Does not accept pipeline input. .OUTPUTS System.Boolean Returns $true if the string is a valid image, $false if not. .NOTES Author: https://github.com/dan-metzler PowerShellVersion: PowerShell 5.1 or later. Requires the System.Drawing assembly, which is loaded automatically. .LINK https://github.com/dan-metzler/PSWorkbench #> [CmdletBinding()] [OutputType([bool])] param ( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Base64String ) begin { Add-Type -AssemblyName System.Drawing } process { $base64 = $Base64String -replace '^data:image/[^;]+;base64,', '' try { $bytes = [Convert]::FromBase64String($base64) $ms = [System.IO.MemoryStream]::new($bytes) $img = [System.Drawing.Image]::FromStream($ms) $img.Dispose() $ms.Dispose() return $true } catch { return $false } } } #EndRegion '.\Public\Test-Base64Image.ps1' 70 #Region '.\Public\Update-GenericList.ps1' -1 function Update-GenericList { <# .SYNOPSIS Sanitizes and normalizes user input arrays by removing whitespaces, converting case, and filtering null/duplicate values .DESCRIPTION The **Update-GenericList** function processes user input arrays to standardize and clean the data. It provides flexible options to remove whitespaces, convert case (uppercase or lowercase), remove null/empty items, and eliminate duplicates. .PARAMETER UserInput Array of strings that require sanitization and normalization. This parameter is mandatory and accepts empty collections. .PARAMETER RemoveWhitespaces Switch parameter to remove all whitespace characters from each input item using regex replacement. .PARAMETER Trim Switch parameter to remove leading and trailing whitespace from each input item while preserving internal spaces. .PARAMETER ConvertToLowercase Switch parameter to convert all input items to lowercase. Cannot be used simultaneously with ConvertToUppercase. .PARAMETER ConvertToUppercase Switch parameter to convert all input items to uppercase. Cannot be used simultaneously with ConvertToLowercase. .PARAMETER RemoveNullOrEmptyItems Switch parameter to filter out null, empty, or whitespace-only items from the input array. .PARAMETER RemoveDuplicates Switch parameter to remove duplicate values from the processed array using Select-Object -Unique. .EXAMPLE Update-GenericList -UserInput @(" User1 ", "USER2", "user1") -RemoveWhitespaces -ConvertToLowercase -RemoveDuplicates Returns: @("user1", "user2") - removes spaces, converts to lowercase, and eliminates duplicates .EXAMPLE Update-GenericList -UserInput @("account1", "", "ACCOUNT2", $null) -ConvertToUppercase -RemoveNullOrEmptyItems Returns: @("ACCOUNT1", "ACCOUNT2") - converts to uppercase and removes null/empty items .EXAMPLE Update-GenericList -UserInput @(" John Doe ", " Jane Smith ", "Bob Jones") -Trim -RemoveDuplicates Returns: @("John Doe", "Jane Smith", "Bob Jones") - removes leading/trailing spaces while preserving internal spaces .NOTES Author: https://github.com/dan-metzler PowerShellVersion: PowerShell 5.1 or Later Recommended #> [CmdletBinding()] param ( [Parameter( Mandatory = $true, HelpMessage = "Array of strings that require sanitization and normalization." )] [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$UserInput, [Parameter( )] [switch]$RemoveWhitespaces, [Parameter( )] [switch]$Trim, [Parameter()] [switch]$ConvertToLowercase, [Parameter()] [switch]$ConvertToUppercase, [Parameter()] [switch]$RemoveNullOrEmptyItems, [Parameter()] [switch]$RemoveDuplicates ) # PARAMETER VALIDATION, ONE MUST BE SET TO TRUE if (-Not($RemoveWhitespaces -or $Trim -or $ConvertToLowercase -or $ConvertToUppercase)) { throw "At least one of the boolean parameters [RemoveWhitespaces, Trim, ConvertToLowercase, ConvertToUppercase] must be set to `$true" } if ($ConvertToLowercase -and $ConvertToUppercase) { throw "[ConvertToLowercase] and [ConvertToUppercase] CANNOT both be set at the same time, only one can be chosen" } # IF WHITE SPACE SWITCH IS SELECTED, CLEAN THE INPUT DATA AND GET RID OF ANY SPACES $LIST_CollectUpdatedItems = [System.Collections.Generic.List[string]]::New() if ($RemoveWhitespaces -and $ConvertToLowercase) { for ($i = 0; $i -lt $UserInput.Count; $i++) { $LIST_CollectUpdatedItems.Add($UserInput[$i].Replace(' ', '').ToLowerInvariant()) } Write-Verbose "Cleaned User Input :: [Removed Whitespaces & Converted To Lowercase]" } elseif ($RemoveWhitespaces -and $ConvertToUppercase) { for ($i = 0; $i -lt $UserInput.Count; $i++) { $LIST_CollectUpdatedItems.Add($UserInput[$i].Replace(' ', '').ToUpperInvariant()) } Write-Verbose "Cleaned User Input :: [Removed Whitespaces & Converted To Uppercase]" } elseif ($RemoveWhitespaces) { for ($i = 0; $i -lt $UserInput.Count; $i++) { $LIST_CollectUpdatedItems.Add($UserInput[$i].Replace(' ', '')) } Write-Verbose "Cleaned User Input :: [Removed Whitespaces Only]" } elseif ($ConvertToLowercase -and (-Not($RemoveWhitespaces))) { for ($i = 0; $i -lt $UserInput.Count; $i++) { $LIST_CollectUpdatedItems.Add($UserInput[$i].ToLowerInvariant()) } Write-Verbose "Cleaned User Input :: [Converted To Lowercase]" } elseif ($ConvertToUppercase -and (-Not($RemoveWhitespaces))) { for ($i = 0; $i -lt $UserInput.Count; $i++) { $LIST_CollectUpdatedItems.Add($UserInput[$i].ToUpperInvariant()) } Write-Verbose "Cleaned User Input :: [Converted To Uppercase]" } elseif ($Trim -and (-Not($RemoveWhitespaces -or $ConvertToLowercase -or $ConvertToUppercase))) { for ($i = 0; $i -lt $UserInput.Count; $i++) { $LIST_CollectUpdatedItems.Add($UserInput[$i].Trim()) } Write-Verbose "Cleaned User Input :: [Trimmed Leading/Trailing Whitespace]" } # Apply Trim processing if specified along with other operations if ($Trim -and ($ConvertToLowercase -or $ConvertToUppercase -or $RemoveWhitespaces)) { for ($i = 0; $i -lt $LIST_CollectUpdatedItems.Count; $i++) { $LIST_CollectUpdatedItems[$i] = $LIST_CollectUpdatedItems[$i].Trim() } Write-Verbose "Cleaned User Input :: [Trimmed Leading/Trailing Whitespace]" } if ($RemoveNullOrEmptyItems) { $revised_list = [System.Collections.Generic.List[string]]::New() $removed_null_empty_counter = 0 for ($i = 0; $i -lt $LIST_CollectUpdatedItems.Count; $i++) { if ([string]::IsNullOrEmpty($LIST_CollectUpdatedItems[$i].Trim())) { #Write-Verbose "String is Null or Empty, Removed From List :: [$i]" $removed_null_empty_counter++ } else { $revised_list.Add($LIST_CollectUpdatedItems[$i]) } } if ($removed_null_empty_counter -ne 0) { Write-Verbose "Number of null or empty items removed from list :: [$($removed_null_empty_counter)]" } if ($RemoveDuplicates) { $deduped = [System.Collections.Generic.List[string]]::new() foreach ($item in ($revised_list | Select-Object -Unique)) { $deduped.Add($item) } Write-Output -NoEnumerate $deduped } else { Write-Output -NoEnumerate $revised_list } } else { if ($RemoveDuplicates) { $deduped = [System.Collections.Generic.List[string]]::new() foreach ($item in ($LIST_CollectUpdatedItems | Select-Object -Unique)) { $deduped.Add($item) } Write-Output -NoEnumerate $deduped } else { Write-Output -NoEnumerate $LIST_CollectUpdatedItems } } } #EndRegion '.\Public\Update-GenericList.ps1' 181 |