interop.ps1
|
#Requires -Version 5.0 function Remove-ComObject { <# .SYNOPSIS Releases one or more COM objects. .DESCRIPTION Wraps Marshal.ReleaseComObject with null checks and best-effort error handling so scripts can safely clean up Outlook and Office interop objects from finally blocks. .EXAMPLE PS> Remove-ComObject $items $folder .LINK https://github.com/adnoctem/winkit/lib/interop.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '', Justification = 'COM cleanup must be best-effort during script teardown.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Releases local COM references only; it does not change external system state.')] [CmdletBinding()] param ( [Parameter(ValueFromRemainingArguments = $true)] [AllowNull()] [object[]] $InputObject ) foreach ($_object in $InputObject) { if ($null -eq $_object) { continue } try { if ([System.Runtime.InteropServices.Marshal]::IsComObject($_object)) { [void][System.Runtime.InteropServices.Marshal]::ReleaseComObject($_object) } } catch { } } } function Invoke-ComGarbageCollection { <# .SYNOPSIS Runs final COM cleanup garbage collection passes. .DESCRIPTION Forces garbage collection and waits for pending finalizers. This is useful after releasing Office COM references so Outlook can close PST files and exit cleanly when requested. .EXAMPLE PS> Invoke-ComGarbageCollection .LINK https://github.com/adnoctem/winkit/lib/interop.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [CmdletBinding()] param () [GC]::Collect() [GC]::WaitForPendingFinalizers() } function Get-OutlookInstallation { <# .SYNOPSIS Finds local Outlook installation directories. .DESCRIPTION Discovers common Microsoft Office and Microsoft 365 installation roots that may contain Outlook.exe or Outlook data-file repair tools such as ScanPST.exe. The function checks App Paths registry entries first, then common Office directory layouts under Program Files. .EXAMPLE PS> Get-OutlookInstallation .LINK https://github.com/adnoctem/winkit/lib/interop.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [OutputType([PSCustomObject[]])] [CmdletBinding()] param () $_candidateDirectories = New-Object System.Collections.Generic.List[string] $_registryPaths = @( 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\OUTLOOK.EXE', 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\OUTLOOK.EXE', 'Registry::HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\OUTLOOK.EXE' ) foreach ($_registryPath in $_registryPaths) { if (-not (Test-Path -LiteralPath $_registryPath)) { continue } $_property = Get-ItemProperty -LiteralPath $_registryPath -ErrorAction SilentlyContinue if (-not $_property) { continue } $_outlookPath = $_property.'(default)' if ([string]::IsNullOrWhiteSpace($_outlookPath) -and ($_property.PSObject.Properties.Name -contains 'Path')) { $_outlookPath = Join-Path -Path $_property.Path -ChildPath 'OUTLOOK.EXE' } if (-not [string]::IsNullOrWhiteSpace($_outlookPath)) { $_directory = Split-Path -Path ([Environment]::ExpandEnvironmentVariables($_outlookPath)) -Parent if (-not [string]::IsNullOrWhiteSpace($_directory)) { $_candidateDirectories.Add($_directory) } } } $_programRoots = @( $env:ProgramFiles, ${env:ProgramFiles(x86)} ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique $_officeVersions = @('Office16', 'Office15', 'Office14', 'Office12', 'Office11') foreach ($_programRoot in $_programRoots) { $_officeRoot = Join-Path -Path $_programRoot -ChildPath 'Microsoft Office' foreach ($_version in $_officeVersions) { $_candidateDirectories.Add((Join-Path -Path $_officeRoot -ChildPath $_version)) $_candidateDirectories.Add((Join-Path -Path $_officeRoot -ChildPath "root\$_version")) } } $_seen = New-Object System.Collections.Generic.HashSet[string]([System.StringComparer]::OrdinalIgnoreCase) foreach ($_directory in $_candidateDirectories) { if ([string]::IsNullOrWhiteSpace($_directory)) { continue } if (-not (Test-Path -LiteralPath $_directory -PathType Container)) { continue } $_resolvedDirectory = Resolve-LongPath -LiteralPath $_directory if (-not $_seen.Add($_resolvedDirectory)) { continue } $_outlookPath = Join-Path -Path $_resolvedDirectory -ChildPath 'OUTLOOK.EXE' $_scanPstPath = Join-Path -Path $_resolvedDirectory -ChildPath 'SCANPST.EXE' if ( -not (Test-Path -LiteralPath $_outlookPath -PathType Leaf) -and -not (Test-Path -LiteralPath $_scanPstPath -PathType Leaf) ) { continue } [PSCustomObject]@{ Path = $_resolvedDirectory OutlookPath = if (Test-Path -LiteralPath $_outlookPath -PathType Leaf) { $_outlookPath } else { $null } ScanPstPath = if (Test-Path -LiteralPath $_scanPstPath -PathType Leaf) { $_scanPstPath } else { $null } } } } function Get-OutlookRepairToolInfo { <# .SYNOPSIS Reads repair-tool metadata and identifies supported ScanPST file targeting. .DESCRIPTION Inspects an existing executable without launching it. Only ScanPST from the Office 16 family at build 16.0.10325.20082 or later is classified as supporting the documented file argument and rescan execution mode. Older, unknown, and explicitly selected other executables remain interactive. SupportsFileArgument is a conservative inference from the executable's version resource, not a runtime capability probe. MSI and Click-to-Run file versions can differ; a false value selects the interactive fallback. The directory name alone is not evidence of command-line support. .PARAMETER LiteralPath Literal path to the repair executable, including a legacy explicit override. .EXAMPLE PS> Get-OutlookRepairToolInfo -LiteralPath 'C:\Program Files\Microsoft Office\root\Office16\SCANPST.EXE' #> [OutputType([PSCustomObject])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [string] $LiteralPath ) $_path = Resolve-LongPath -LiteralPath $LiteralPath $_file = Get-Item -LiteralPath $_path -ErrorAction Stop if ($_file.PSIsContainer -or $_file.Extension -ne '.exe') { throw 'The repair tool must be an existing .exe file.' } $_name = [IO.Path]::GetFileNameWithoutExtension($_path) $_version = $null $_versionInfo = $_file.VersionInfo if ($_versionInfo -and $_versionInfo.FileMajorPart -gt 0) { $_version = [version]('{0}.{1}.{2}.{3}' -f $_versionInfo.FileMajorPart, $_versionInfo.FileMinorPart, $_versionInfo.FileBuildPart, $_versionInfo.FilePrivatePart) } $_supportsFileArgument = $_name -ieq 'SCANPST' -and $null -ne $_version -and $_version.Major -eq 16 -and $_version -ge [version]'16.0.10325.20082' [PSCustomObject]@{ Name = $_name Path = $_path InstallationPath = $_file.DirectoryName FileVersion = $_version SupportsFileArgument = [bool]$_supportsFileArgument } } function Find-OutlookRepairTool { <# .SYNOPSIS Finds installed ScanPST repair tools and their targeting capabilities. .DESCRIPTION Searches Outlook installations, then application executables on PATH. Returned paths use long names and include executable version metadata. Legacy alternatives can be inspected explicitly with Get-OutlookRepairToolInfo. .PARAMETER Name ScanPST is the only automatically discovered repair tool. .EXAMPLE PS> Find-OutlookRepairTool -Name ScanPST #> [OutputType([PSCustomObject[]])] [CmdletBinding()] param ( [ValidateSet('ScanPST')] [string] $Name = 'ScanPST' ) $_seen = New-Object 'Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($_installation in Get-OutlookInstallation) { $_path = $_installation.ScanPstPath if ([string]::IsNullOrWhiteSpace($_path)) { continue } $_tool = Get-OutlookRepairToolInfo -LiteralPath $_path if ($_seen.Add($_tool.Path)) { $_tool } } foreach ($_command in @(Get-Command -Name ($Name + '.exe') -CommandType Application -ErrorAction SilentlyContinue)) { $_tool = Get-OutlookRepairToolInfo -LiteralPath $_command.Source if ($_seen.Add($_tool.Path)) { $_tool } } } function Get-TransportMessageId { <# .SYNOPSIS Extracts the RFC Message-ID from transport header text. .DESCRIPTION Parses a raw Outlook transport header block and returns the first RFC 5322 Message-ID field value, or $null when none is present. Headers may be supplied in Unicode or ANSI form; field matching is case-insensitive and line-based so embedded Received headers do not interfere. .PARAMETER HeaderText Raw transport header text as returned by Outlook (PR_TRANSPORT_MESSAGE_HEADERS). .EXAMPLE PS> Get-TransportMessageId -HeaderText "Message-ID: <abc123@example.com>`r`nReceived: ..." .LINK https://github.com/adnoctem/winkit/lib/interop.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [OutputType([string])] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $HeaderText ) if ([string]::IsNullOrWhiteSpace($HeaderText)) { return $null } $_match = [regex]::Match($HeaderText, '(?im)^Message-ID:\s*(<[^>]+>)') if ($_match.Success) { return $_match.Groups[1].Value.Trim() } return $null } function Connect-Outlook { <# .SYNOPSIS Connects to an Outlook COM application and MAPI namespace. .DESCRIPTION Reuses a running Outlook instance when available, otherwise starts one, then logs on to the MAPI namespace without prompting. .EXAMPLE PS> $context = Connect-Outlook .LINK https://github.com/adnoctem/winkit/lib/interop.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingEmptyCatchBlock', '', Justification = 'Falling back to a new Outlook COM instance is intentional.')] [OutputType([PSCustomObject])] [CmdletBinding()] param () try { $_application = [System.Runtime.InteropServices.Marshal]::GetActiveObject('Outlook.Application') } catch { $_application = New-Object -ComObject Outlook.Application } $_namespace = $_application.GetNamespace('MAPI') $_namespace.Logon($null, $null, $false, $false) [PSCustomObject]@{ App = $_application Namespace = $_namespace } } function Get-OutlookStoreRoot { <# .SYNOPSIS Gets the root folder for an Outlook store. .DESCRIPTION Resolves a named Outlook store by DisplayName, or the default delivery store when no name is supplied. Store COM objects are released as they are inspected; the returned root folder is owned by the caller. .PARAMETER Namespace Outlook MAPI namespace returned by Connect-Outlook. .PARAMETER Name Optional store display name. .EXAMPLE PS> Get-OutlookStoreRoot -Namespace $context.Namespace -Name 'user@example.com' .LINK https://github.com/adnoctem/winkit/lib/interop.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [object] $Namespace, [string] $Name ) $_stores = $Namespace.Stores try { Write-Verbose 'Available Outlook stores:' for ($_index = 1; $_index -le $_stores.Count; $_index++) { $_store = $_stores.Item($_index) try { Write-Verbose (" - {0}" -f $_store.DisplayName) if ([string]::IsNullOrWhiteSpace($Name) -and $_store.IsDefault) { return $_store.GetRootFolder() } if ($_store.DisplayName -eq $Name) { return $_store.GetRootFolder() } } finally { Remove-ComObject $_store } } } finally { Remove-ComObject $_stores } throw "Outlook store '$Name' not found. Run with -Verbose to list available stores." } function Add-OutlookStoreRoot { <# .SYNOPSIS Adds a Unicode PST store and returns its root folder. .DESCRIPTION Calls Outlook Namespace.AddStoreEx with OlStoreType.olStoreUnicode and locates the newly attached store by FilePath. The returned root folder is owned by the caller. .PARAMETER Namespace Outlook MAPI namespace returned by Connect-Outlook. .PARAMETER Path Full path to the PST file to attach or create. .EXAMPLE PS> Add-OutlookStoreRoot -Namespace $context.Namespace -Path 'D:\Archive\mail.pst' .LINK https://github.com/adnoctem/winkit/lib/interop.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [object] $Namespace, [Parameter(Mandatory = $true)] [string] $Path ) $_olStoreUnicode = 2 $_resolvedPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) $Namespace.AddStoreEx($_resolvedPath, $_olStoreUnicode) $_stores = $Namespace.Stores try { for ($_index = 1; $_index -le $_stores.Count; $_index++) { $_store = $_stores.Item($_index) try { if ($_store.FilePath -eq $_resolvedPath) { return $_store.GetRootFolder() } } finally { Remove-ComObject $_store } } } finally { Remove-ComObject $_stores } throw "Outlook store was added but could not be located by path: $_resolvedPath" } function Get-PSFOutlookPathDriveType { [CmdletBinding()] param ([string]$Path) (New-Object IO.DriveInfo([IO.Path]::GetPathRoot($Path))).DriveType } function Resolve-PSFOutlookFilePath { [CmdletBinding()] param ( [string]$LiteralPath, [switch]$SourcePst ) if ([string]::IsNullOrWhiteSpace($LiteralPath)) { throw 'A nonempty local file path is required.' } $_provider = $null $_drive = $null $_path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($LiteralPath, [ref]$_provider, [ref]$_drive) if ($_provider.Name -ne 'FileSystem' -or $_path -notmatch '^[A-Za-z]:\\') { throw "A local filesystem path is required: '$LiteralPath'." } if ((Get-PSFOutlookPathDriveType $_path) -notin @([IO.DriveType]::Fixed, [IO.DriveType]::Removable)) { throw "Network or unavailable drives are not supported: '$_path'." } $_file = Get-Item -LiteralPath $_path -Force -ErrorAction Stop if ($_file.PSIsContainer) { throw "Expected an existing file: '$_path'." } if ($SourcePst -and ($_file.Extension -ne '.pst' -or ($_file.Attributes -band [IO.FileAttributes]::ReadOnly))) { throw "The source must be an existing writable .pst file: '$_path'." } # Reject traversal rather than claiming to resolve symlink/junction identities. $_ancestor = $_path while ($_ancestor) { $_item = Get-Item -LiteralPath $_ancestor -Force -ErrorAction Stop if ($_item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse-point paths are not supported for Outlook file identity: '$_ancestor'." } $_ancestor = Split-Path -Path $_ancestor -Parent } Resolve-LongPath -LiteralPath $_path } function Get-PSFOutlookProperty { [CmdletBinding()] param ([object]$InputObject, [string]$Name) if ($null -eq $InputObject) { throw "Cannot read Outlook property '$Name' from a null reference." } $_property = $InputObject.PSObject.Properties[$Name] if ($null -eq $_property) { throw "Outlook property '$Name' is unavailable." } # Ordinary PowerShell property syntax can silently turn a throwing getter into # null. Calling the accessor as a method preserves the inspection failure. return , ($_property.get_Value()) } function Get-PSFOutlookPstSnapshot { [CmdletBinding()] param ([object]$Namespace, [string]$Path) $_ids = New-Object 'Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) $_matches = New-Object 'Collections.Generic.List[object]' $_stores = $null try { $_stores = Get-PSFOutlookProperty $Namespace Stores $_count = Get-PSFOutlookProperty $_stores Count if ($null -eq $_count -or $_count -isnot [int] -or $_count -lt 0) { throw 'Outlook returned an invalid store count.' } for ($_index = 1; $_index -le $_count; $_index++) { $_store = $null try { $_store = $_stores.Item($_index) $_id = [string](Get-PSFOutlookProperty $_store StoreID) if ([string]::IsNullOrWhiteSpace($_id) -or -not $_ids.Add($_id)) { throw 'Outlook returned missing or duplicate StoreIDs.' } $_filePath = [string](Get-PSFOutlookProperty $_store FilePath) if ([string]::IsNullOrWhiteSpace($_filePath)) { continue } $_resolved = Resolve-PSFOutlookFilePath $_filePath if ([StringComparer]::OrdinalIgnoreCase.Equals($_resolved, $Path)) { $_matches.Add([PSCustomObject]@{ StoreId = $_id; DisplayName = [string](Get-PSFOutlookProperty $_store DisplayName) }) } } finally { Remove-ComObject $_store } } [PSCustomObject]@{ Ids = $_ids; Matches = $_matches.ToArray() } } catch { throw (New-Object InvalidOperationException("Cannot inspect Outlook stores for PST '$Path': $($_.Exception.Message)", $_.Exception)) } finally { Remove-ComObject $_stores } } function Get-PSFOutlookPstRoot { [CmdletBinding()] param ([object]$Namespace, [string]$Path, [string]$StoreId) $_store = $null $_root = $null try { $_store = $Namespace.GetStoreFromID($StoreId) if ((Get-PSFOutlookProperty $_store StoreID) -ne $StoreId -or (Resolve-PSFOutlookFilePath (Get-PSFOutlookProperty $_store FilePath)) -ne $Path) { throw "Outlook store identity changed for PST '$Path'." } $_root = $_store.GetRootFolder() if ($null -eq $_root -or (Get-PSFOutlookProperty $_root StoreID) -ne $StoreId) { throw "Outlook returned no matching root for PST '$Path'." } $_owned = $_root $_root = $null return , $_owned } finally { Remove-ComObject $_root $_store } } function Remove-PSFOutlookPstAttachment { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Mandatory cleanup of a uniquely identified attachment created by Open; not a new optional user operation.')] [CmdletBinding()] param ([object]$Namespace, [string]$Path, [string]$StoreId, [object]$Root) $_snapshot = Get-PSFOutlookPstSnapshot -Namespace $Namespace -Path $Path if (-not $_snapshot.Ids.Contains($StoreId)) { return } if (@($_snapshot.Matches | Where-Object { $_.StoreId -ieq $StoreId }).Count -ne 1) { throw "The recorded Outlook StoreID no longer matches PST '$Path'." } $_temporaryRoot = $null try { if ($null -eq $Root) { $_temporaryRoot = Get-PSFOutlookPstRoot -Namespace $Namespace -Path $Path -StoreId $StoreId $Root = $_temporaryRoot } if ((Get-PSFOutlookProperty $Root StoreID) -ne $StoreId) { throw "The owned Outlook root no longer matches PST '$Path'." } $null = $Namespace.RemoveStore($Root) } finally { Remove-ComObject $_temporaryRoot } } function New-PSFOutlookPstContext { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an in-process lifetime object only.')] [CmdletBinding()] param ([string]$Path, [object]$Match, [object]$Root, [object]$Namespace, [bool]$AttachedByCall) # Keep the cleanup identity separate from the caller's informational fields. $_state = [PSCustomObject]@{ Path = $Path; StoreId = $Match.StoreId; Root = $Root; Namespace = $Namespace; AttachedByCall = $AttachedByCall; Closed = $false } [PSCustomObject]@{ PSTypeName = 'PSFoundation.OutlookPstStoreContext' Path = $Path; StoreId = $Match.StoreId; DisplayName = $Match.DisplayName Root = $Root; Namespace = $Namespace; AttachedByCall = $AttachedByCall; Closed = $false _State = $_state } } function Open-OutlookPstStore { <# .SYNOPSIS Opens an existing local PST and returns an ownership-aware lifetime context. .DESCRIPTION Reuses a uniquely matching profile attachment or adds the existing file with Namespace.AddStore. Does not create destinations, rename stores or choose a format. ANSI compatibility needs native validation with the installed Outlook. Requires a writable local PST; UNC, network drives and reparse traversal are rejected. Relative paths use PowerShell's current filesystem location and short names are expanded. Hard-link aliases are not detected. The returned PSFoundation.OutlookPstStoreContext owns Root and borrows Namespace. Path, StoreId and DisplayName describe the observed store; AttachedByCall marks attachment ownership and Closed tracks cleanup. Private _State is implementation state. Do not edit, serialize or reuse it across processes. Release child COM references before Close-OutlookPstStore, and keep the creating context alive until overlapping consumers finish; contexts are not reference-counted leases. Standalone WhatIf/declined confirmation returns no context when attachment is needed. An explicitly documented inspection preview may override WhatIf for this call only, then must close in finally. Outlook may update PST metadata. Existence is rechecked before AddStore, but Outlook has no atomic existing-only open. Do not remove/replace the file or change profile attachments concurrently. .PARAMETER Namespace Borrowed MAPI namespace, normally returned by Connect-Outlook. Never released here. .PARAMETER LiteralPath Existing writable local .pst file. Wildcards are literal; directories are rejected. .EXAMPLE $source = $null try { $source = Open-OutlookPstStore -Namespace $context.Namespace -LiteralPath '.\Archive.pst' if ($null -ne $source) { $source | Select-Object Path, StoreId, DisplayName, AttachedByCall } } finally { if ($null -ne $source) { Close-OutlookPstStore -Context $source } } .EXAMPLE Open-OutlookPstStore -Namespace $context.Namespace -LiteralPath 'D:\Archives\Old mail.pst' -WhatIf Previews a required attachment without adding it. A pre-existing match is returned normally. .LINK https://learn.microsoft.com/en-us/office/vba/api/outlook.namespace.addstore #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [OutputType([PSCustomObject])] param ( [Parameter(Mandatory = $true)] [ValidateNotNull()] [object]$Namespace, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$LiteralPath ) $_path = Resolve-PSFOutlookFilePath -LiteralPath $LiteralPath -SourcePst $_snapshot = Get-PSFOutlookPstSnapshot -Namespace $Namespace -Path $_path if ($_snapshot.Matches.Count -gt 1) { throw "Multiple Outlook stores match PST '$_path'." } if (-not $_snapshot.Matches.Count) { if (-not $PSCmdlet.ShouldProcess($_path, 'Attach existing PST to the current Outlook profile')) { return } # Account for attachments that appeared while approval was pending. $_snapshot = Get-PSFOutlookPstSnapshot -Namespace $Namespace -Path $_path if ($_snapshot.Matches.Count -gt 1) { throw "Multiple Outlook stores match PST '$_path'." } } $_initialIds = $_snapshot.Ids $_attempted = $false $_ownedId = $null $_root = $null try { if (-not $_snapshot.Matches.Count) { if ((Resolve-PSFOutlookFilePath -LiteralPath $_path -SourcePst) -ne $_path) { throw "PST path changed before attachment: '$_path'." } $_attempted = $true $null = $Namespace.AddStore($_path) $_snapshot = Get-PSFOutlookPstSnapshot -Namespace $Namespace -Path $_path if ($_snapshot.Matches.Count -ne 1 -or $_initialIds.Contains($_snapshot.Matches[0].StoreId)) { throw "No uniquely identified new Outlook attachment for PST '$_path'." } $_ownedId = $_snapshot.Matches[0].StoreId } $_match = $_snapshot.Matches[0] $_root = Get-PSFOutlookPstRoot -Namespace $Namespace -Path $_path -StoreId $_match.StoreId $_context = New-PSFOutlookPstContext -Path $_path -Match $_match -Root $_root -Namespace $Namespace -AttachedByCall ([bool]$_ownedId) $_root = $null return $_context } catch { $_failure = $_ if ($_attempted) { try { if (-not $_ownedId) { $_current = Get-PSFOutlookPstSnapshot -Namespace $Namespace -Path $_path if ($_current.Matches.Count -gt 1) { throw 'Cannot identify a unique attachment to clean up.' } if ($_current.Matches.Count -eq 1 -and -not $_initialIds.Contains($_current.Matches[0].StoreId)) { $_ownedId = $_current.Matches[0].StoreId } } if ($_ownedId) { Remove-PSFOutlookPstAttachment -Namespace $Namespace -Path $_path -StoreId $_ownedId -Root $_root } } catch { $_cleanup = "Cleanup of PST '$_path' could not be completed: $($_.Exception.Message)" $_failure.Exception.Data['OutlookPstCleanupError'] = $_cleanup $_failure.ErrorDetails = New-Object Management.Automation.ErrorDetails("$($_failure.Exception.Message) $_cleanup") } } throw $_failure } finally { Remove-ComObject $_root } } function Close-OutlookPstStore { <# .SYNOPSIS Releases an existing-PST context and detaches only its owned attachment. .DESCRIPTION Checks the recorded StoreID and path before detaching an attachment created by Open-OutlookPstStore. Pre-existing stores and replacement StoreIDs are left alone. Always releases the owned root, clears Root and sets Closed, even on failure. Subsequent calls do nothing. The namespace/application remain borrowed and live. Never deletes, renames, replaces, repairs or compacts files, or quits Outlook. This is mandatory finally cleanup, not a second optional profile operation: it does not prompt or honor inherited WhatIf suppression. It can only undo the attachment identified by its context. Detach/inspection failure is terminating and identifies the PST path; the context is closed but the attachment may remain. Callers must release all child COM references first and report cleanup failure without hiding any original processing error. Concurrent profile changes and unobservable reuse of the same store identity cannot be made transactional. .PARAMETER Context Live context returned by Open-OutlookPstStore. Guard null in the caller. .EXAMPLE if ($null -ne $source) { Close-OutlookPstStore -Context $source } Releases a context in finally without releasing its borrowed namespace. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Required lifetime cleanup must undo the context-owned attachment even under inherited WhatIf; no new operation is authorized.')] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [ValidateNotNull()] [object]$Context ) if ($Context.PSObject.TypeNames -notcontains 'PSFoundation.OutlookPstStoreContext' -or -not $Context.PSObject.Properties['_State']) { throw 'Context must be a live object returned by Open-OutlookPstStore.' } $_state = $Context._State if ($_state.Closed) { return } try { if ($_state.AttachedByCall) { Remove-PSFOutlookPstAttachment -Namespace $_state.Namespace -Path $_state.Path -StoreId $_state.StoreId -Root $_state.Root } } catch { throw (New-Object InvalidOperationException("Cannot detach PST '$($_state.Path)': $($_.Exception.Message)", $_.Exception)) } finally { Remove-ComObject $_state.Root $_state.Root = $null $_state.Closed = $true $Context.Root = $null $Context.Closed = $true } } function Get-OutlookSubFolder { <# .SYNOPSIS Gets or creates an Outlook child folder. .DESCRIPTION Searches a parent folder's Folders collection by display name. When -Create is set, the folder is created if missing. The returned folder is owned by the caller. .PARAMETER ParentFolder Outlook parent folder. .PARAMETER Name Child folder display name. .PARAMETER Create Create the folder when it does not exist. .EXAMPLE PS> Get-OutlookSubFolder -ParentFolder $root -Name '_Review' -Create .LINK https://github.com/adnoctem/winkit/lib/interop.ps1 .NOTES Author: MVProwess <info@mvprowess.com> License: MIT #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [object] $ParentFolder, [Parameter(Mandatory = $true)] [string] $Name, [switch] $Create ) $_folders = $ParentFolder.Folders try { for ($_index = 1; $_index -le $_folders.Count; $_index++) { $_folder = $_folders.Item($_index) if ($_folder.Name -eq $Name) { return $_folder } Remove-ComObject $_folder } if ($Create) { return $_folders.Add($Name) } } finally { Remove-ComObject $_folders } return $null } function Get-OutlookStandardFolderIdentity { <# .SYNOPSIS Reads locale-independent standard folder identities for one Outlook store. .DESCRIPTION Returns plain records keyed by StoreID and EntryID. Outlook 2010 and later use Store.GetDefaultFolder. Outlook 2007 uses read-only MAPI properties, with the default Inbox as an additional property source. No localized folder names are used. Missing optional properties are reported separately from unexpected provider errors. Does not create optional default folders. .PARAMETER Namespace Connected Outlook MAPI namespace. .PARAMETER StoreRoot Root folder of the selected store, owned by the caller. .EXAMPLE PS> Get-OutlookStandardFolderIdentity -Namespace $context.Namespace -StoreRoot $root #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [object] $Namespace, [Parameter(Mandatory = $true)] [object] $StoreRoot ) $_definitions = [ordered]@{ DeletedItems = 3 Outbox = 4 SentItems = 5 Inbox = 6 Calendar = 9 Contacts = 10 Journal = 11 Notes = 12 Tasks = 13 Drafts = 16 AllPublicFolders = 18 Conflicts = 19 SyncIssues = 20 LocalFailures = 21 ServerFailures = 22 Junk = 23 RssFeeds = 25 ToDo = 28 ManagedEmail = 29 SuggestedContacts = 30 } $_store = $null $_default = $null $_inbox = $null $_accessors = New-Object Collections.ArrayList try { $_store = $StoreRoot.Store $_application = $Namespace.Application try { $_major = [int](($_application.Version -split '\.')[0]) } finally { Remove-ComObject $_application } if ($_major -ge 14) { foreach ($_definition in $_definitions.GetEnumerator()) { $_folder = $null try { $_folder = $_store.GetDefaultFolder($_definition.Value) if ($_folder -and ([string]::IsNullOrWhiteSpace([string]$_folder.EntryID) -or $_folder.StoreID -ne $StoreRoot.StoreID)) { throw 'Standard folder identity is empty or belongs to another store.' } [PSCustomObject]@{ Kind = $_definition.Key StoreID = [string]$StoreRoot.StoreID EntryID = if ($_folder) { [string]$_folder.EntryID } else { $null } State = if ($_folder) { 'Resolved' } else { 'Absent' } Evidence = 'Store.GetDefaultFolder' } } catch { $_exception = $_.Exception while ($_exception.InnerException) { $_exception = $_exception.InnerException } # MAPI_E_NOT_FOUND and MAPI_E_NO_SUPPORT describe unavailable optional # folders. Access denied, disconnected providers, and other errors fail. if ($_exception.HResult -notin @(-2147221233, -2147221246) -and -not ($_exception.HResult -eq -2147024809 -and $_definition.Key -in @('AllPublicFolders', 'ManagedEmail', 'SuggestedContacts', 'ToDo', 'RssFeeds'))) { throw "Cannot determine standard folder '$($_definition.Key)' in the selected store: $($_.Exception.Message)" } [PSCustomObject]@{ Kind = $_definition.Key StoreID = [string]$StoreRoot.StoreID EntryID = $null State = 'Unavailable' Evidence = 'Store.GetDefaultFolder' } } finally { Remove-ComObject $_folder } } return } $_known = @{} if (-not $_store.IsDataFileStore -or [IO.Path]::GetExtension([string]$_store.FilePath) -ne '.pst') { throw 'Outlook 2007 standard-folder discovery currently supports PST stores only. Use a newer classic Outlook client for Exchange or OST stores.' } $_default = $Namespace.DefaultStore if ($_default.StoreID -eq $StoreRoot.StoreID) { # Inbox is mandatory in a default delivery store. Do not request optional # folders through Namespace.GetDefaultFolder, which can create them. $_inbox = $Namespace.GetDefaultFolder(6) if ($_inbox.StoreID -ne $StoreRoot.StoreID) { throw 'Default Inbox belongs to a different store.' } $_known.Inbox = [string]$_inbox.EntryID $null = $_accessors.Add($_inbox.PropertyAccessor) } $null = $_accessors.Add($StoreRoot.PropertyAccessor) $null = $_accessors.Add($_store.PropertyAccessor) $_tags = [ordered]@{ Outbox = '35E2' DeletedItems = '35E3' SentItems = '35E4' Calendar = '36D0' Contacts = '36D1' Journal = '36D2' Notes = '36D3' Tasks = '36D4' Drafts = '36D7' } foreach ($_accessor in $_accessors) { foreach ($_tag in $_tags.GetEnumerator()) { if ($_known.ContainsKey($_tag.Key)) { continue } try { $_binary = $_accessor.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x$($_tag.Value)0102") if ($_binary -and $_binary.Length -gt 0) { $_known[$_tag.Key] = $_accessor.BinaryToString($_binary) } } catch { $_exception = $_.Exception while ($_exception.InnerException) { $_exception = $_exception.InnerException } if ($_exception.HResult -ne -2147221233) { throw } } } try { $_additional = $_accessor.GetProperty('http://schemas.microsoft.com/mapi/proptag/0x36D81102') $_kinds = @('Conflicts', 'SyncIssues', 'LocalFailures', 'ServerFailures', 'Junk') for ($_index = 0; $_index -lt [math]::Min($_additional.Length, $_kinds.Count); $_index++) { if ($_additional[$_index] -and -not $_known.ContainsKey($_kinds[$_index])) { $_known[$_kinds[$_index]] = $_accessor.BinaryToString($_additional[$_index]) } } } catch { $_exception = $_.Exception while ($_exception.InnerException) { $_exception = $_exception.InnerException } if ($_exception.HResult -ne -2147221233) { throw } } # PR_ADDITIONAL_REN_ENTRYIDS_EX contains bounded PersistData blocks. try { [byte[]]$_data = $_accessor.GetProperty('http://schemas.microsoft.com/mapi/proptag/0x36D90102') $_offset = 0 while ($_offset -lt $_data.Length) { if ($_data.Length - $_offset -lt 4) { throw 'Truncated PersistData header.' } $_id = [BitConverter]::ToUInt16($_data, $_offset) $_size = [BitConverter]::ToUInt16($_data, $_offset + 2) $_offset += 4 if ($_id -eq 0) { break } $_end = $_offset + $_size if ($_end -gt $_data.Length) { throw 'PersistData exceeds property bounds.' } $_kind = switch ($_id) { 0x8001 { 'RssFeeds' } 0x8004 { 'ToDo' } 0x8008 { 'SuggestedContacts' } } if (-not $_kind) { $_offset = $_end continue } while ($_offset -lt $_end) { if ($_end - $_offset -lt 4) { throw 'Truncated PersistElement header.' } $_elementId = [BitConverter]::ToUInt16($_data, $_offset) $_length = [BitConverter]::ToUInt16($_data, $_offset + 2) $_offset += 4 if ($_offset + $_length -gt $_end) { throw 'PersistElement exceeds block bounds.' } if ($_elementId -eq 0) { if ($_length -ne 0) { throw 'ELEMENT_SENTINEL must have zero length.' } $_offset = $_end break } if ($_elementId -eq 1 -and $_length -gt 0 -and -not $_known.ContainsKey($_kind)) { [byte[]]$_entry = $_data[$_offset..($_offset + $_length - 1)] $_known[$_kind] = $_accessor.BinaryToString($_entry) } $_offset += $_length } } } catch { $_exception = $_.Exception while ($_exception.InnerException) { $_exception = $_exception.InnerException } if ($_exception.HResult -ne -2147221233) { throw } } } foreach ($_definition in $_definitions.GetEnumerator()) { [PSCustomObject]@{ Kind = $_definition.Key StoreID = [string]$StoreRoot.StoreID EntryID = $_known[$_definition.Key] State = if ($_known.ContainsKey($_definition.Key)) { 'Resolved' } elseif ($_definition.Key -eq 'Inbox') { 'Unresolved' } else { 'Absent' } Evidence = 'Outlook2007.MAPI' } } } finally { foreach ($_accessor in $_accessors) { Remove-ComObject $_accessor } Remove-ComObject $_inbox $_default $_store } } function Get-OutlookFolderPlan { <# .SYNOPSIS Builds a read-only, locale-independent Outlook folder processing plan. .DESCRIPTION Resolves one exact store-relative path and optionally its descendants. Standard folders require inclusion by identity, custom exclusions win, and search folders are always excluded. Only mail items are eligible; included non-mail containers permit traversal to mail subfolders. Returns plain metadata; callers reopen selected folders by EntryID and StoreID. The entire plan must be collected successfully before mutation. .PARAMETER Namespace Connected Outlook MAPI namespace. .PARAMETER StoreRoot Root folder of the selected store, owned by the caller. .PARAMETER FolderName Exact store-relative path when explicitly supplied. Empty selects the root. When omitted, selects the store's Inbox by identity regardless of its name. If that identity cannot be resolved, supply an explicit path; no fallback to a name or the store root is attempted. .PARAMETER Recurse Visit descendants of the selected folder. .PARAMETER Include Standard folder kinds permitted within the selected scope. Default Inbox. .PARAMETER Exclusions Exact store-relative folder paths to exclude with their descendants. .PARAMETER ProgressId Progress record identifier used during enumeration. .EXAMPLE PS> Get-OutlookFolderPlan -Namespace $context.Namespace -StoreRoot $root -FolderName Posteingang -Recurse .EXAMPLE PS> Get-OutlookFolderPlan -Namespace $context.Namespace -StoreRoot $root Selects the Inbox by identity, including when localized or renamed. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Nested traversal functions read Recurse and ProgressId from the parent scope.')] [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [object] $Namespace, [Parameter(Mandatory = $true)] [object] $StoreRoot, [AllowEmptyString()] [string] $FolderName = 'Inbox', [switch] $Recurse, [ValidateSet('DeletedItems', 'Outbox', 'SentItems', 'Inbox', 'Calendar', 'Contacts', 'Journal', 'Notes', 'Tasks', 'Drafts', 'AllPublicFolders', 'Conflicts', 'SyncIssues', 'LocalFailures', 'ServerFailures', 'Junk', 'RssFeeds', 'ToDo', 'ManagedEmail', 'SuggestedContacts')] [string[]] $Include = @('Inbox'), [string[]] $Exclusions = @(), [int] $ProgressId = 0 ) if (@($Exclusions | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) { throw 'Exclusions must contain nonblank store-relative paths.' } foreach ($_path in @($FolderName) + @($Exclusions)) { if ($_path -eq '' -and $_path -eq $FolderName) { continue } foreach ($_segment in $_path.Split('\')) { if ([string]::IsNullOrWhiteSpace($_segment) -or $_segment -in @('.', '..')) { throw 'Folder paths must be exact, nonblank store-relative paths. Only FolderName may be empty for the store root.' } } } $_identities = @(Get-OutlookStandardFolderIdentity -Namespace $Namespace -StoreRoot $StoreRoot) $_identityById = @{} foreach ($_identity in $_identities) { if ($_identity.State -eq 'Unresolved' -and $_identity.Kind -notin $Include) { throw "Cannot enforce exclusion of '$($_identity.Kind)' on this store and Outlook version. Select a supported store or explicitly include this kind." } if ($_identity.EntryID) { $_identityById[$_identity.EntryID] = $_identity.Kind } } $_implicitInboxId = $null if (-not $PSBoundParameters.ContainsKey('FolderName')) { $_inboxIdentities = @($_identities | Where-Object { $_.Kind -eq 'Inbox' }) if ($_inboxIdentities.Count -ne 1 -or $_inboxIdentities[0].State -ne 'Resolved' -or [string]::IsNullOrWhiteSpace([string]$_inboxIdentities[0].EntryID) -or $_inboxIdentities[0].StoreID -ne $StoreRoot.StoreID) { throw 'Cannot resolve the selected store Inbox identity. Supply FolderName explicitly.' } $_implicitInboxId = [string]$_inboxIdentities[0].EntryID $_inbox = $null try { $_inbox = $Namespace.GetFolderFromID($_implicitInboxId, [string]$StoreRoot.StoreID) if (-not $_inbox -or $_inbox.StoreID -ne $StoreRoot.StoreID -or $_inbox.EntryID -ne $_implicitInboxId) { throw 'Resolved Inbox does not match the selected store and folder identity.' } $_rootPrefix = ([string]$StoreRoot.FolderPath).TrimEnd('\') + '\' $_inboxPath = [string]$_inbox.FolderPath if (-not $_inboxPath.StartsWith($_rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Resolved Inbox is outside the selected store root.' } $FolderName = $_inboxPath.Substring($_rootPrefix.Length) foreach ($_segment in $FolderName.Split('\')) { if ([string]::IsNullOrWhiteSpace($_segment) -or $_segment -in @('.', '..')) { throw 'Resolved Inbox does not have an exact store-relative folder path.' } } } finally { Remove-ComObject $_inbox } # Walk the resolved path below so exclusions on every ancestor still apply. } function Get-FolderDecision { param ( [object] $Folder, [string] $RelativePath ) $_kind = $_identityById[[string]$Folder.EntryID] $_reason = $null foreach ($_excluded in $Exclusions) { if ($RelativePath -ieq $_excluded -or $RelativePath.StartsWith($_excluded + '\', [StringComparison]::OrdinalIgnoreCase)) { $_reason = 'CustomExclusion' break } } if (-not $_reason -and $_kind -and $_kind -notin $Include) { $_reason = "StandardFolder:$($_kind):Include$($_kind) required" } $_accessor = $Folder.PropertyAccessor try { if ($_accessor.GetProperty('http://schemas.microsoft.com/mapi/proptag/0x36010003') -eq 2) { $_reason = 'SearchFolder' } } finally { Remove-ComObject $_accessor } [PSCustomObject]@{ EntryID = [string]$Folder.EntryID StoreID = [string]$Folder.StoreID FolderPath = [string]$Folder.FolderPath RelativePath = $RelativePath StandardKind = $_kind Process = -not [bool]$_reason -and $Folder.DefaultItemType -eq 0 Traverse = -not [bool]$_reason Reason = if ($_reason) { $_reason } elseif ($Folder.DefaultItemType -ne 0) { 'NonMailContainer' } else { 'Included' } } } function Get-FolderTreePlan { param ( [object] $Folder, [string] $RelativePath ) Write-Progress -Id $ProgressId -Activity 'Outlook folders' -Status 'Reading folder identities and applying exclusions' -CurrentOperation $Folder.FolderPath $_decision = Get-FolderDecision -Folder $Folder -RelativePath $RelativePath $_decision if (-not $Recurse -or -not $_decision.Traverse) { return } $_folders = $Folder.Folders try { for ($_index = 1; $_index -le $_folders.Count; $_index++) { $_child = $_folders.Item($_index) try { $_relative = if ($RelativePath) { $RelativePath + '\' + $_child.Name } else { [string]$_child.Name } Get-FolderTreePlan -Folder $_child -RelativePath $_relative } finally { Remove-ComObject $_child } } } finally { Remove-ComObject $_folders } } $_selected = $StoreRoot $_relative = '' try { if ($FolderName -ne '') { foreach ($_segment in $FolderName.Split('\')) { $_next = Get-OutlookSubFolder -ParentFolder $_selected -Name $_segment if (-not $_next) { throw "FolderName '$FolderName' was not found. Use the displayed folder path, for example Posteingang." } if ($_selected -ne $StoreRoot) { Remove-ComObject $_selected } $_selected = $_next $_relative = if ($_relative) { $_relative + '\' + $_selected.Name } else { [string]$_selected.Name } $_decision = Get-FolderDecision -Folder $_selected -RelativePath $_relative if (-not $_decision.Traverse) { throw "Selected folder is excluded by '$($_decision.Reason)' at '$_relative'." } } } if ($_implicitInboxId -and $_selected.EntryID -ne $_implicitInboxId) { throw 'Inbox identity changed while resolving its path. Request a new folder plan.' } Get-FolderTreePlan -Folder $_selected -RelativePath $_relative } finally { if ($_selected -ne $StoreRoot) { Remove-ComObject $_selected } } } |