Private/Kinds/OutlookData.ps1
|
# The OutlookData Kind: the mail file, the sync window, and which Office this is. # # An OST past about 20 GB is the single most common reason a Customer says "Outlook is # slow" on a machine with nothing else wrong with it, and the fix - a shorter cached mode # sync window - is why the window is read in the same Check rather than a different one. # # Which Office this is belongs here too, because an Office build Microsoft no longer # services, or a product Microsoft 365 no longer serves at all, is the other answer to # "Outlook keeps losing the connection" that nothing on the network side will find. # The update channels Click-to-Run reports, by the GUID in its CDN address, named as the # Office Deployment Tool and the MinimumBuild parameter name them. Source: the table # "Update channels for Microsoft 365 Apps" in # https://learn.microsoft.com/en-us/intune/configmgr/sum/deploy-use/manage-office-365-proplus-updates # and the list in https://learn.microsoft.com/en-us/intune/device-configuration/settings-catalog/update-office # (both read 2026-09-25). The LTSC and Office 2019 volume channels are left out: no # Microsoft page found lists their GUIDs, and a guessed name would be a guessed verdict. $script:OfficeChannelByGuid = @{ '492350f6-3a01-4f97-b9c0-c7c6ddf67d60' = 'Current' '64256afe-f5d9-4f86-8936-8840a6a4f5be' = 'CurrentPreview' '55336b82-a18d-4dd6-b5f6-9e5095c314a6' = 'MonthlyEnterprise' '7ffbc6bf-bc32-4f92-8982-f9dd17fd3114' = 'SemiAnnual' 'b8f9b850-328d-4355-9145-c59439a0c4cf' = 'SemiAnnualPreview' '5440fd1f-7ecb-4221-8110-145efaa6372f' = 'BetaChannel' } function ConvertTo-OfficeChannelName { <# .SYNOPSIS Names the update channel Click-to-Run reported, or returns nothing. Pure. .DESCRIPTION Click-to-Run reports its channel as a CDN address ending in a GUID, and policy can name it as the Office Deployment Tool does ("MonthlyEnterprise"). Both are read. Anything else - a channel this module has no source for, a file share an administrator points updates at - names nothing, so the Judge says it does not know rather than judging the build against the wrong channel. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyString()][string]$Channel) if (-not $Channel -or -not $Channel.Trim()) { return } $guid = [regex]::Match($Channel, '[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}') if ($guid.Success) { return $script:OfficeChannelByGuid[$guid.Value.ToLowerInvariant()] } # The names themselves, returned in this module's spelling whatever case they came in. foreach ($name in $script:OfficeChannelByGuid.Values) { if ($name -eq $Channel.Trim()) { return $name } } } # The Office products that are installed beside a suite and bring no Outlook with them: # Visio, Project, a single program on its own (Word, Excel, PowerPoint, Access, Publisher, # OneNote), Skype for Business (Lync), SharePoint Designer, the Access Runtime, the # proofing tools and the language packs. A 2019 Visio next to Microsoft 365 Apps connects # no mailbox, so judging it would warn about a connection that does not exist (#19). # # Recognised by exclusion rather than by a list of suites on purpose: a product this list # does not know - a new SKU, a name in a language nobody has seen - is still judged, so # the mistake it can make is a WARN too many, never an unsupported Outlook passed as OK. # # Click-to-Run IDs by prefix, as "Product IDs supported by the Office Deployment Tool for # Click-to-Run" names them (VisioPro2021Retail, ProjectStd2021Volume, Excel2021Retail, # AccessRuntimeRetail, LanguagePack, OneNoteFreeRetail, SkypeforBusiness2021Volume): # https://learn.microsoft.com/en-us/troubleshoot/microsoft-365-apps/office-suite-issues/product-ids-supported-office-deployment-click-to-run # (read 2026-09-25). ProofingTools and SPDRetail are not on that page and are from # memory, as are the MSI uninstall keys' SKUs after "Office16." (VISPRO, PRJSTD, # OMUI.<culture>, PROOFKIT, LYNC, SHAREPOINTDESIGNER) - which is why the display name is # read as well, in English and German. Outlook itself (OutlookRetail, Outlook2019Volume) # is never set aside. $script:OfficeCompanionIdPattern = '^(Visio|Project|Word|Excel|PowerPoint|Access|Publisher|OneNote|ProofingTools|LanguagePack|SkypeForBusiness|Lync|SPD|SharePointDesigner)' + '|^Office\d+\.(VIS|PRJ|WORD|EXCEL|POWERPOINT|ACCESS|PUB|ONENOTE|OMUI|PROOF|LYNC|SPD|SHAREPOINTDESIGNER)' $script:OfficeCompanionNamePattern = '^Microsoft (Word|Excel|PowerPoint|Access|Publisher)\b' + '|\b(Visio|Project|OneNote|Skype for Business|Lync|SharePoint Designer)\b' + '|Proofing|Korrekturhilfen|Language Pack|Language Interface|Sprachpaket|Sprachoberfl' + '|Access Runtime|Access-Runtime' function Test-OfficeCompanionProduct { <# .SYNOPSIS Whether an Office product is one installed beside a suite, bringing no Outlook. Pure. .DESCRIPTION By its Click-to-Run ID or MSI uninstall key, or by its display name; see $script:OfficeCompanionIdPattern for which products and why by exclusion. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyString()][string]$Id, [AllowNull()][AllowEmptyString()][string]$Name) ($Id -and $Id -match $script:OfficeCompanionIdPattern) -or ($Name -and $Name -match $script:OfficeCompanionNamePattern) } function Get-OutlookDataData { [CmdletBinding()] [OutputType([psobject])] param([hashtable]$Parameters = @{}) $documents = [Environment]::GetFolderPath('MyDocuments') # Every Outlook value that could not be read, as { Setting; Error }. A value that is not # there and one that could not be read are different facts: the first means Outlook's # default applies, the second that nobody knows (see Read-OutlookRegistryValue). $settingErrors = [Collections.Generic.List[object]]::new() # ForceOSTPath and ForcePSTPath put a new profile's mail files wherever an administrator # said, which is how an OST ends up on a share nobody thought to look at (KB 2752583). # Both locations are searched, policy and preference, because a value left behind in # either still placed the files of every profile created while it was there. $forced = @{} foreach ($name in 'ForceOSTPath', 'ForcePSTPath') { $forced[$name] = @(foreach ($key in 'HKCU:\Software\Policies\Microsoft\Office\16.0\Outlook', 'HKCU:\Software\Microsoft\Office\16.0\Outlook') { $read = Read-OutlookRegistryValue -Path $key -Name $name if ($read.Error) { $settingErrors.Add([pscustomobject]@{ Setting = $name; Error = $read.Error }); continue } $value = $read.Value if ($value -and "$value".Trim()) { [Environment]::ExpandEnvironmentVariables("$value".Trim()) } }) } # The German folder name is listed beside the English one because the estate is # German and Outlook names this folder in the installed language. $directories = @( Join-Path $env:LOCALAPPDATA 'Microsoft\Outlook' Join-Path $documents 'Outlook Files' Join-Path $documents 'Outlook-Dateien' $forced['ForceOSTPath'] $forced['ForcePSTPath'] ) $files = @(foreach ($directory in $directories) { if (-not $directory -or -not (Test-Path -LiteralPath $directory)) { continue } # Filtered by extension here, not with -Include: Windows PowerShell 5.1 ignores # -Include beside -LiteralPath and returned every .oab, .dat and .nst in the folder # as a mail file, where PowerShell 7 returned the five real ones. Get-ChildItem -LiteralPath $directory -File -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Extension -in '.ost', '.pst' } | ForEach-Object { [pscustomobject]@{ Name = $_.Name Path = $_.FullName SizeBytes = $_.Length # A folder redirected to a share reads as a UNC path, so this answers # for redirection as well as for a mapped drive. IsRemote = Test-RemoteLocation -Path $_.FullName } } }) # Policy first: a Customer whose administrator set the window centrally has an answer # a Technician cannot change on the machine, and it has to be the one reported. $syncWindow = Get-OutlookSetting -SubKey 'Cached Mode' -Name 'SyncWindowSetting' -Errors $settingErrors # Profiles are counted, never named: their names are whatever the user typed, and one # of them is often the user's own name. $profilesKey = 'HKCU:\Software\Microsoft\Office\16.0\Outlook\Profiles' $profileCount = $null if (Test-Path -LiteralPath $profilesKey) { $profileCount = @(Get-ChildItem -LiteralPath $profilesKey -ErrorAction SilentlyContinue).Count } # Outlook's Application events, counted by id: 26 is "Connection to Microsoft Exchange # has been lost" and 59 an add-in Outlook disabled for slowing it down (see the research # file, check 10). By id and provider only, as Stability reads its events, because the # message text is German here. A log this Run may not read leaves both counts absent, # never zero: a refused log and a quiet one must not look alike. $days = [int](Get-Parameter $Parameters 'Days' 7) $unreadable = @{} $events = @(Get-StabilityEvent -Log 'Application' -Provider @('Outlook') -Id @(26, 59) ` -Since (Get-Date).AddDays(-$days) -Unreadable $unreadable) $connectionLost = $null $addinsDisabled = $null if (-not $unreadable.ContainsKey('Application')) { $connectionLost = @($events | Where-Object { $_.Id -eq 26 }).Count $addinsDisabled = @($events | Where-Object { $_.Id -eq 59 }).Count } $unreadableLogs = @($unreadable.Keys) $clickToRun = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Office\ClickToRun\Configuration' -ErrorAction SilentlyContinue # Which Office products are installed, by ID and by the name Programs and Features # shows. Both, because a Click-to-Run Office 2016 retail ID carries no year # ("HomeBusinessRetail") while its name does, and an MSI Office 2016 has no # Click-to-Run ID at all - only its "Office16.<SKU>" uninstall entry. $uninstall = @(foreach ($root in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall') { Get-ChildItem -LiteralPath $root -ErrorAction SilentlyContinue | ForEach-Object { [pscustomobject]@{ Key = $_.PSChildName Name = (Get-ItemProperty -LiteralPath $_.PSPath -ErrorAction SilentlyContinue).DisplayName } } }) $productIds = @("$($clickToRun.ProductReleaseIds)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) $products = @( foreach ($id in $productIds) { # Click-to-Run registers one entry per product and language: "<ID> - de-de". $entry = $uninstall | Where-Object { $_.Key -like "$id - *" -and $_.Name } | Select-Object -First 1 [pscustomobject]@{ Id = $id; Name = $(if ($entry) { $entry.Name } else { $null }) } } foreach ($entry in @($uninstall | Where-Object { $_.Key -match '^Office\d+\.' })) { [pscustomobject]@{ Id = $entry.Key; Name = $entry.Name } } ) [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.OutlookData' MailFiles = @($files | Sort-Object Path -Unique) SyncWindowMonths = $syncWindow OfficeVersion = $clickToRun.VersionToReport OfficePlatform = $clickToRun.Platform OfficeChannel = $(if ($clickToRun.UpdateChannel) { $clickToRun.UpdateChannel } else { $clickToRun.CDNBaseUrl }) OfficeProducts = $products ForceOstPath = @($forced['ForceOSTPath']) | Select-Object -First 1 ForcePstPath = @($forced['ForcePSTPath']) | Select-Object -First 1 # In MB, as Outlook reads them (KB 832925). Under the PST key, but Outlook applies # them to OST files as well. MaxLargeFileSizeMB = Get-OutlookSetting -SubKey 'PST' -Name 'MaxLargeFileSize' -Errors $settingErrors WarnLargeFileSizeMB = Get-OutlookSetting -SubKey 'PST' -Name 'WarnLargeFileSize' -Errors $settingErrors # Shared folders cached in Cached Mode count toward the OST (KB 982697). CacheOthersMail = Get-OutlookSetting -SubKey 'Cached Mode' -Name 'CacheOthersMail' -Errors $settingErrors DownloadSharedFolders = Get-OutlookSetting -SubKey 'Cached Mode' -Name 'DownloadSharedFolders' -Errors $settingErrors SharedFolderSyncWindowDays = Get-OutlookSetting -SubKey 'Cached Mode' -Name 'SharedFolderSyncWindowSettingDays' -Errors $settingErrors # Named as the Judge asks for them: "PST\WarnLargeFileSize", "ForceOSTPath". SettingErrors = @($settingErrors) ProfileCount = $profileCount DefaultProfile = (Get-ItemProperty 'HKCU:\Software\Microsoft\Office\16.0\Outlook' -ErrorAction SilentlyContinue).DefaultProfile EventDays = $days ConnectionLostEvents = $connectionLost AddinDisabledEvents = $addinsDisabled UnreadableLogs = @($unreadableLogs | Sort-Object) } } function Read-OutlookRegistryValue { <# .SYNOPSIS One registry value as { Value; Error }. Never throws. .DESCRIPTION A key that does not exist, or a value it does not hold, is Value $null with no Error: whatever Windows or Outlook does by default applies. A key that is there but could not be read - access refused, a broken hive - is an Error, because reading it as absent would report the default as the answer on a machine where nobody knows. Get-ItemProperty -ErrorAction SilentlyContinue made exactly that mistake, so the OutlookAuth Kind reads its values through here too. #> [CmdletBinding()] [OutputType([psobject])] param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Name) try { $item = Get-ItemProperty -LiteralPath $Path -ErrorAction Stop } catch [System.Management.Automation.ItemNotFoundException] { return [pscustomobject]@{ Value = $null; Error = $null } } catch { return [pscustomobject]@{ Value = $null; Error = $_.Exception.Message } } $value = $null $property = $item.PSObject.Properties[$Name] if ($property) { $value = $property.Value } [pscustomobject]@{ Value = $value; Error = $null } } function Get-OutlookSetting { <# .SYNOPSIS One Outlook 16.0 value, policy key first, or $null when neither key sets it. .DESCRIPTION Policy first, because a value an administrator set centrally is the one Outlook obeys and the one a Technician cannot change on the machine. A key that could not be read ends the search with $null and an entry in Errors: below an unread policy, the preference is not the value Outlook obeys, only possibly so. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$SubKey, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][AllowEmptyCollection()][Collections.Generic.List[object]]$Errors ) foreach ($root in 'HKCU:\Software\Policies\Microsoft\Office\16.0\Outlook', 'HKCU:\Software\Microsoft\Office\16.0\Outlook') { $read = Read-OutlookRegistryValue -Path (Join-Path $root $SubKey) -Name $Name if ($read.Error) { $Errors.Add([pscustomobject]@{ Setting = ('{0}\{1}' -f $SubKey, $Name); Error = $read.Error }) return $null } if ($null -ne $read.Value) { return $read.Value } } $null } function ConvertTo-OutlookDataFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) New-MailFileFinding -Data $Data -Parameters $Parameters New-MailFileLocationFinding -Data $Data -Parameters $Parameters New-SyncWindowFinding -Data $Data -Parameters $Parameters New-OfficeBuildFinding -Data $Data -Parameters $Parameters New-OfficeBuildSupportFinding -Data $Data -Parameters $Parameters New-OfficeProductFinding -Data $Data -Parameters $Parameters New-ConnectionLostFinding -Data $Data -Parameters $Parameters New-AddinsDisabledEventFinding -Data $Data -Parameters $Parameters New-SharedFolderCachingFinding -Data $Data -Parameters $Parameters New-OutlookProfileFinding -Data $Data -Parameters $Parameters } # Outlook's own size limits when the registry sets none, in MB: MaxLargeFileSize 51,200 # (50 GB) and WarnLargeFileSize 48,640 (47.5 GB), per KB 832925 # (https://learn.microsoft.com/en-us/microsoft-365-apps/outlook/data-files/configure-size-limit-outlook-data-files). # Facts about Outlook rather than thresholds: a Definition cannot change what Outlook does # when nothing is set, so these are not parameters. $script:OutlookDefaultMaxLargeFileSizeMB = 51200 $script:OutlookDefaultWarnLargeFileSizeMB = 48640 function Get-OutlookSettingError { <# .SYNOPSIS Why the Gatherer could not read an Outlook value ("PST\WarnLargeFileSize"), or $null. Pure. .DESCRIPTION An unread value arrives as $null, the same as an unset one, so every Judge that would otherwise take $null for Outlook's default asks here first. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Data, [Parameter(Mandatory)][string]$Setting) $entry = (Get-DataCollection $Data 'SettingErrors') | Where-Object { "$(Get-DataProperty $_ 'Setting')" -eq $Setting } | Select-Object -First 1 if ($entry) { "$(Get-DataProperty $entry 'Error')" } } function Get-OutlookFileLimit { <# .SYNOPSIS The size in GB at which Outlook considers a mail file full, and where it came from. .DESCRIPTION At WarnLargeFileSize Outlook already stops sending mail (KB 832925), so the lower of the two values is where the file is full. A value that is not a positive number is ignored, because Outlook falls back to its default for it too. #> [CmdletBinding()] param([AllowNull()]$Data) $limitMB = $null $fromRegistry = $false foreach ($pair in @( @('WarnLargeFileSizeMB', $script:OutlookDefaultWarnLargeFileSizeMB), @('MaxLargeFileSizeMB', $script:OutlookDefaultMaxLargeFileSizeMB))) { $set = ConvertTo-Number (Get-DataProperty $Data $pair[0]) $mb = $pair[1] $isSet = $null -ne $set -and $set -gt 0 if ($isSet) { $mb = $set } if ($null -eq $limitMB -or $mb -lt $limitMB) { $limitMB = $mb; $fromRegistry = $isSet } } [pscustomobject]@{ GB = $limitMB / 1024; FromRegistry = $fromRegistry } } function New-MailFileFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $warnAboveGB = Get-Parameter $Parameters 'MailFileWarnAboveGB' 20 $failAboveGB = Get-Parameter $Parameters 'MailFileFailAboveGB' 45 $files = Get-DataCollection $Data 'MailFiles' if (-not $files.Count) { return New-Finding -Category Apps -Check (Get-Text 'Check.OutlookData.MailFilesOSTPST') -Severity INFO ` -Value (Get-Text 'Value.OutlookData.NoneForThisUser') ` -Hint (Get-Text 'Hint.OutlookData.OnlineModeNewOutlookOr') } # Graded against whichever is lower, the Definition's threshold or the limit Outlook # itself enforces: a registry value that lowers Outlook's limit must lower the grade, # and one that raises it must not lift a Definition's threshold with it. $outlook = Get-OutlookFileLimit -Data $Data $failAt = [double]$failAboveGB $source = Get-Text 'Value.OutlookData.LimitFromDefinition' # Outlook's limit unread: only the Definition's thresholds are known, so the file is # graded against them alone, and what would be OK is INFO - the unread limit may sit # below the file already. $limitUnread = (Get-OutlookSettingError $Data 'PST\WarnLargeFileSize') -or (Get-OutlookSettingError $Data 'PST\MaxLargeFileSize') if ($limitUnread) { $source = Get-Text 'Value.OutlookData.LimitUnreadable' } elseif ($outlook.GB -lt $failAt) { $failAt = $outlook.GB $source = $(if ($outlook.FromRegistry) { Get-Text 'Value.OutlookData.LimitFromRegistry' } else { Get-Text 'Value.OutlookData.LimitFromOutlookDefault' }) } $warnAt = [math]::Min([double]$warnAboveGB, $failAt) $atOutlookLimit = $failAt -lt [double]$failAboveGB # One Finding per file. A mailbox at 8 GB beside an archive at 48 GB is not honestly # described by either of them, and only one of the two is the thing to act on. foreach ($file in $files) { if (Get-DataProperty $file 'IsRemote') { New-Finding -Category Apps -Check ((Get-Text 'Check.OutlookData.MailFilePlacement') -f $file.Name) ` -Severity WARN -Value "$(Get-DataProperty $file 'Path')" ` -Hint (Get-Text 'Hint.OutlookData.MailFileOnNetwork') } $bytes = ConvertTo-Number $file.SizeBytes if ($null -eq $bytes) { New-UnavailableFinding -Category Apps -Check ((Get-Text 'Check.OutlookData.MailFile') -f $file.Name) ` -Hint (Get-Text 'Hint.OutlookData.TheFileWouldNotReport') continue } $gb = $bytes / 1GB $severity = Get-Severity $gb $warnAt $failAt $hint = Get-Text 'Hint.OutlookData.LargeOSTPSTSlowOutlook' if ($severity -eq 'FAIL' -and $atOutlookLimit) { $hint = Get-Text 'Hint.OutlookData.MailFileAtOutlookLimit' $failAt } if ($severity -eq 'OK' -and $limitUnread) { $severity = 'INFO' $hint = Get-Text 'Hint.OutlookData.LimitUnreadable' } New-Finding -Category Apps -Check ((Get-Text 'Check.OutlookData.MailFile') -f $file.Name) ` -Severity $severity -Value (Get-Text 'Value.OutlookData.SizeAgainstLimit' $gb $failAt $source) ` -Hint $hint } } function New-MailFileLocationFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) # An unread ForceOSTPath or ForcePSTPath is a folder the Gatherer never searched, so # the mail files above may not be all of them. Said only then: a readable value is # already reflected in the files found. $unread = @(foreach ($setting in 'ForceOSTPath', 'ForcePSTPath') { $failure = Get-OutlookSettingError $Data $setting if ($failure) { Get-Text 'Value.OutlookData.SettingUnreadable' $setting $failure } }) if (-not $unread.Count) { return } New-Finding -Category Apps -Check (Get-Text 'Check.OutlookData.MailFileLocations') -Severity INFO ` -Value ($unread -join '; ') -Hint (Get-Text 'Hint.OutlookData.MailFileLocationsUnreadable') } function New-ConnectionLostFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) # Unsourced: Microsoft names no number of lost connections that is too many. Five in a # week is more than a laptop changing networks explains (Inference, research check 10). $warnAbove = Get-Parameter $Parameters 'ConnectionLostWarnAbove' 5 $check = Get-Text 'Check.OutlookData.ConnectionLost' $count = ConvertTo-Number (Get-DataProperty $Data 'ConnectionLostEvents') if ($null -eq $count) { # A refused log says so in words; a count that is missing for any other reason is # still not a zero. if ((Get-DataCollection $Data 'UnreadableLogs') -contains 'Application') { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value (Get-Text 'Value.OutlookData.ApplicationLogUnreadable') ` -Hint (Get-Text 'Hint.OutlookData.ApplicationLogUnreadable') } return New-UnavailableFinding -Category Apps -Check $check -Hint (Get-Text 'Hint.OutlookData.ApplicationLogUnreadable') } $days = Get-DataProperty $Data 'EventDays' $severity = 'OK' if ($count -gt [double]$warnAbove) { $severity = 'WARN' } New-Finding -Category Apps -Check $check -Severity $severity ` -Value (Get-Text 'Value.OutlookData.EventsInDays' $count $days) ` -Hint (Get-Text 'Hint.OutlookData.ConnectionLost' $count $days) } function New-AddinsDisabledEventFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) # Never graded: the Office add-ins Check says which add-ins load and which are # disabled. This says only that Outlook itself stepped in, and how often. $count = ConvertTo-Number (Get-DataProperty $Data 'AddinDisabledEvents') if (-not $count) { return } New-Finding -Category Apps -Check (Get-Text 'Check.OutlookData.AddinsDisabledEvents') -Severity INFO ` -Value (Get-Text 'Value.OutlookData.EventsInDays' $count (Get-DataProperty $Data 'EventDays')) ` -Hint (Get-Text 'Hint.OutlookData.AddinsDisabledEvents') } function ConvertTo-OutlookSwitchText { [CmdletBinding()] [OutputType([string])] param([AllowNull()]$Value, [switch]$Unread) if ($Unread) { return (Get-Text 'Value.OutlookData.Unreadable') } $number = ConvertTo-Number $Value if ($null -eq $number) { return (Get-Text 'Value.OutlookData.NotSet') } if ($number -eq 0) { return (Get-Text 'Value.OutlookData.Off') } Get-Text 'Value.OutlookData.On' } function New-SharedFolderCachingFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $cacheOthers = Get-DataProperty $Data 'CacheOthersMail' $download = Get-DataProperty $Data 'DownloadSharedFolders' $days = ConvertTo-Number (Get-DataProperty $Data 'SharedFolderSyncWindowDays') $profiles = ConvertTo-Number (Get-DataProperty $Data 'ProfileCount') # An unread value is not "not set", and it is said even without a profile: nobody # knows what it holds. $unreadOthers = [bool](Get-OutlookSettingError $Data 'Cached Mode\CacheOthersMail') $unreadDownload = [bool](Get-OutlookSettingError $Data 'Cached Mode\DownloadSharedFolders') $unreadDays = [bool](Get-OutlookSettingError $Data 'Cached Mode\SharedFolderSyncWindowSettingDays') $unread = $unreadOthers -or $unreadDownload -or $unreadDays # Only where Outlook has been set up: on a machine without a profile, "not set" three # times over describes nothing. if (-not $profiles -and -not $unread -and $null -eq $cacheOthers -and $null -eq $download -and $null -eq $days) { return } # Never graded: whether caching other people's mail is right depends on how many # mailboxes this user opens, which is not readable on the machine. The mail file # Findings say whether it has made the OST too large. $window = Get-Text 'Value.OutlookData.NotSet' if ($unreadDays) { $window = Get-Text 'Value.OutlookData.Unreadable' } elseif ($null -ne $days) { $window = Get-Text 'Value.OutlookData.Days' $days } $hint = $null if ($unread) { $hint = Get-Text 'Hint.OutlookData.SettingUnreadable' } New-Finding -Category Apps -Check (Get-Text 'Check.OutlookData.SharedFolderCaching') -Severity INFO ` -Value (Get-Text 'Value.OutlookData.SharedFolderCaching' (ConvertTo-OutlookSwitchText $cacheOthers -Unread:$unreadOthers) ` (ConvertTo-OutlookSwitchText $download -Unread:$unreadDownload) $window) ` -Hint $hint } function New-OutlookProfileFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $count = ConvertTo-Number (Get-DataProperty $Data 'ProfileCount') if ($null -eq $count) { return } # Never graded: a second profile is often a leftover from a migration, and sometimes # the reason a user "has lost" mail, but no number of them is wrong in itself. $default = "$(Get-DataProperty $Data 'DefaultProfile')" $value = '{0:N0}' -f $count if ($default) { $value = Get-Text 'Value.OutlookData.ProfilesWithDefault' $count $default } New-Finding -Category Apps -Check (Get-Text 'Check.OutlookData.Profiles') -Severity INFO -Value $value } function New-SyncWindowFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $check = Get-Text 'Check.OutlookData.CachedModeSyncWindow' $setting = 'Cached Mode\SyncWindowSetting' $failure = Get-OutlookSettingError $Data $setting if ($failure) { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value (Get-Text 'Value.OutlookData.SettingUnreadable' $setting $failure) ` -Hint (Get-Text 'Hint.OutlookData.SettingUnreadable') } # Unset leaves Outlook's own default, which this Finding has never reported. $months = ConvertTo-Number (Get-DataProperty $Data 'SyncWindowMonths') if ($null -eq $months) { return } # Never graded. All mail is the right setting for plenty of people and the wrong one # for anyone with a large mailbox, and this Check cannot tell which it is looking at - # the mail file Finding beside it is what says whether this matters. New-Finding -Category Apps -Check (Get-Text 'Check.OutlookData.CachedModeSyncWindow') -Severity INFO ` -Value $(if ($months -eq 0) { 'All mail' } else { '{0:N0} months' -f $months }) } function New-OfficeBuildFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $version = Get-DataProperty $Data 'OfficeVersion' if (-not $version) { return } New-Finding -Category Apps -Check (Get-Text 'Check.OutlookData.Microsoft365Apps') -Severity INFO ` -Value ('{0} ({1}) channel {2}' -f $version, (Get-DataProperty $Data 'OfficePlatform'), (Get-DataProperty $Data 'OfficeChannel')) } function New-OfficeBuildSupportFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) # The oldest build Microsoft still services, per channel: the lowest build of each # channel in the "Supported Versions" table of # https://learn.microsoft.com/en-us/officeupdates/update-history-microsoft365-apps-by-date # in its revision of 2026-09-22, read 2026-09-25. Current Channel is supported only on # its latest version (2609), Monthly Enterprise on its last three (2606 the oldest), and # Semi-Annual Enterprise now receives Monthly Enterprise builds. Channels the table does # not list are left out on purpose. This goes stale every month, which is why it is a # parameter: MERLIN updates it in the Checks Repo, not in a module release. $minimumBuild = Get-Parameter $Parameters 'MinimumBuild' @{ Current = '16.0.20430.20092' MonthlyEnterprise = '16.0.20131.20260' SemiAnnual = '16.0.20131.20258' } $check = Get-Text 'Check.OutlookData.OfficeBuildSupported' $version = Get-DataProperty $Data 'OfficeVersion' if (-not $version) { # This Check runs only for a selected Outlook, so no Click-to-Run version is an # Office this Judge has no minimum for - MSI, or one it found nowhere - and that is # a gap to name, not a build to pass. The "Microsoft 365 Apps" Finding beside it # stays silent here: it describes a Click-to-Run install, and there is none. return New-Finding -Category Apps -Check $check -Severity INFO ` -Value (Get-Text 'Value.OutlookData.NoClickToRun') -Hint (Get-Text 'Hint.OutlookData.NoClickToRun') } $raw = Get-DataProperty $Data 'OfficeChannel' $channel = ConvertTo-OfficeChannelName -Channel $raw if (-not $channel) { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value (Get-Text 'Value.OutlookData.ChannelUnknown' $version "$raw") ` -Hint (Get-Text 'Hint.OutlookData.ChannelUnknown') } # A Definition's map arrives as a hashtable or, straight from a JSON reader, as an # object; the channel is looked up the same way in either. $minimum = Get-DataProperty $minimumBuild $channel if (-not $minimum) { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value (Get-Text 'Value.OutlookData.NoMinimumForChannel' $version $channel) ` -Hint (Get-Text 'Hint.OutlookData.NoMinimumForChannel') } # [version], never text: as text 16.0.9999.1 sorts after 16.0.20131.20260. $installed = "$version" -as [version] $required = "$minimum" -as [version] if (-not $installed -or -not $required) { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value (Get-Text 'Value.OutlookData.BuildOnChannel' $version $channel $minimum) ` -Hint (Get-Text 'Hint.OutlookData.BuildUnreadable') } $severity = 'OK' if ($installed -lt $required) { $severity = 'WARN' } New-Finding -Category Apps -Check $check -Severity $severity ` -Value (Get-Text 'Value.OutlookData.BuildOnChannel' $version $channel $minimum) ` -Hint (Get-Text 'Hint.OutlookData.UpdateOffice') } function New-OfficeProductFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) # "Support for connection to Microsoft 365 services with Office 2019 and Office 2016 # ended on October 10, 2023": # https://learn.microsoft.com/en-us/microsoft-365-apps/end-of-support/microsoft-365-services-connectivity # Matched as wildcards against both the product's ID and its name, because a # Click-to-Run 2016 retail ID has no year in it and its name does. Office LTSC 2021 # follows on 13 October 2026, by the same page - a Definition edit adding '*2021*'. $unsupported = @(Get-Parameter $Parameters 'UnsupportedProducts' @('*2016*', '*2019*') | Where-Object { "$_".Trim() } | ForEach-Object { "$_".Trim() }) # WARN by default, because the Judge cannot know where the mailbox is: against an # on-prem Exchange 2016, 2019 or SE, Office 2016 and 2019 are still supported clients. # A Definition for a Customer known to be on Microsoft 365 raises it to FAIL. Nothing # lower is accepted - a typo must not turn an unsupported product into a clean result. $severity = "$(Get-Parameter $Parameters 'UnsupportedProductSeverity' 'WARN')".Trim().ToUpperInvariant() if ($severity -notin 'WARN', 'FAIL') { $severity = 'WARN' } $check = Get-Text 'Check.OutlookData.OfficeProductSupported' $products = (Get-DataCollection $Data 'OfficeProducts') # Only the Office that brings Outlook is judged: Visio 2019 or a 2016 language pack # beside Microsoft 365 Apps connects no mailbox to anything. $judged = @(foreach ($product in $products) { $id = "$(Get-DataProperty $product 'Id')" $name = "$(Get-DataProperty $product 'Name')" $shown = $name if (-not $shown) { $shown = $id } if (-not $shown) { continue } if (Test-OfficeCompanionProduct -Id $id -Name $name) { continue } $matched = @($unsupported | Where-Object { ($id -and $id -like $_) -or ($name -and $name -like $_) }) [pscustomobject]@{ Shown = $shown; Unsupported = [bool]$matched.Count } }) # Outlook is there - this Check runs only for it - so no suite found is a gap in what # the Gatherer could see, and a gap is never a clean result. if (-not $judged.Count) { return New-Finding -Category Apps -Check $check -Severity INFO ` -Value (Get-Text 'Value.OutlookData.NoSuiteProduct') -Hint (Get-Text 'Hint.OutlookData.NoSuiteProduct') } $bad = @($judged | Where-Object { $_.Unsupported } | ForEach-Object { $_.Shown } | Select-Object -Unique) if ($bad.Count) { $value = $bad -join ', ' return New-Finding -Category Apps -Check $check -Severity $severity -Value $value ` -Hint (Get-Text 'Hint.OutlookData.UnsupportedProduct' $value) } New-Finding -Category Apps -Check $check -Severity OK ` -Value (@($judged | ForEach-Object { $_.Shown } | Select-Object -Unique) -join ', ') } |