I’ve got a pool of Windows 7 virtual machines dedicated to people who are out of office.
These VMs aren’t configured with differentiacing because we want them to be maintained by System Center Configuration Manager and have them applying security updates for the operating system as well as applications. In other words, they aren’t based on a VDI template, don’t get reset and patched through an offline imaging process. So, the major problem with such a scenario is that after a certain amount of time and many users login on, the VMs’ system drives are getting full. The solution is easy and consists in launching a Powershell script on a regular basis through the tasks scheduler that does the following:
- Check the freespace left on the systemdrive
- based on a threshold specified as parameter (whether there’s less than 20% of freespace left on the drive for example) take remediation actions
- Get additional information on user profiles size
- Delete profiles older that x days specified as parameter
- Get the freespace left on the systemdrive after user profiles removal
- Send a nice HTML based report to the admin
- Step 1: Getting the list of user profiles
There are actually two ways to get the list of user profiles.
We can either query the registry under ‘HKLM\software\microsoft\windows nt\currentversion\profilelist’ or use the win32_userprofile WMI class
Ed Wilson, the Hey scripting guy shows these 2 techniques in the following article entitled “Use PowerShell to Find User Profiles on a Computer”
Well, there’s also a more straight forward technique that consists in querying the last write time of the ntuser.dat (the user hive) file in each folder (representing a username) located under C:\users (the default location of user profiles).
Paradoxically though it may seem but I prefer the WMI approach as it’s the easiest. WMI instances have already all the interesting properties we are looking for. With WMI, you don’t need to convert paths or usernames to SIDs (security identifier), have a exclusion list of SIDs or folder names of special accounts (‘Default User’,’All Users’,’Default’,”Public”,…), you don’t need to understand ‘State’ value in the registry (by the way, will there be an official documentation provided one day ?). All we may have to do is to convert dates.
Function Get-UserProfile
{
Param(
[CmdletBinding()]
[parameter(ValueFromPipeline = $True,ValueFromPipeLineByPropertyName = $True)]
[Alias('CN','__Server','IPAddress','Server')]
[string[]]$Computername = $Env:Computername,
[parameter()]
[Alias('RunAs')]
[System.Management.Automation.Credential()]$Credential = [System.Management.Automation.PSCredential]::Empty
)
Begin
{
# Make sure we run as admin
$usercontext = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
$IsAdmin = $usercontext.IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
if (-not($IsAdmin))
{
Write-Warning "Must run powerShell as Administrator to perform these actions"
return
}
# Prepare HT
$wmiHT = @{
ErrorAction = "Stop"
Query = "Select * FROM Win32_UserProfile WHERE Loaded = $false AND Special = $false"
}
#Supplied Alternate Credentials?
If ($PSBoundParameters['Credential'])
{
$wmiHT.credential = $Credential
}
}
Process
{
$ComputerName | ForEach-Object -Process {
$UserProfiles = $null
$Computer = $_
If ($Computer -eq $Env:Computername)
{
$wmiHT.remove('Credential')
} Else {
$wmiHT += @{Computername = $Computer}
}
try {
$UserProfiles = Get-WmiObject @wmiHT
} catch {
Write-Warning -Message "Failed to query Win32_UserProfile on computer $Computer"
}
if ($UserProfiles)
{
$UserProfiles | ForEach-Object -Process {
$LastUseDate = $Status = $Type = $UserName = $LastDownloadTime = $LastUploadTime = $null
Switch($_.Status)
{
# 0 = 'Temporary' according to :
# http://msdn.microsoft.com/en-us/library/windows/desktop/ee886409%28v=vs.85%29.aspx
# Comment from Thomas Lee also says:
# The actual values for the uint field are (0, 1, 2, 4, 8) where a value of zero denotes unset or default
# and 1 = Temporary, 2 = Roaming, 4 = Mandatory and 8 = Corrupted.
0 { $Status = 'Local'}
1 { $Status = 'Roaming'}
2 { $Status = 'Mandatory'}
3 { $Status = 'Corrupted'}
6 { $Status = 'Roaming'}
8 { $Status = 'Temporary and loaded'}
10 { $Status = 'Temporary'}
default { $Status = $_}
}
if ($_.LastUseTime)
{
$LastUseDate = $_.ConvertToDateTime($_.LastUseTime)
}
if ($_.LastDownloadTime)
{
$LastDownloadTime = $_.ConvertToDateTime($_.LastDownloadTime)
}
if ($_.LastUploadTime)
{
$LastUploadTime = $_.ConvertToDateTime($_.LastUploadTime)
}
if ($_.RoamingConfigured)
{
$Status = "Roaming"
} else {
$Status = $Status
}
if ($_.RoamingPreference)
{
$Type = "Roaming"
} else {
$Type = "Local"
}
if ($_.SID)
{
$UserName = ConvertTo-NtAccount -Sid $($_.SID)
}
New-Object -TypeName PSObject -Property @{
UserName = $UserName
SID = $_.SID
Type = $Type
Status = $Status
Path = $_.LocalPath
LastUsedDate = $LastUseDate
LastUploadTime = $LastUploadTime
LastDownloadTime = $LastDownloadTime
BinaryState = $_.Status
CentralProfile = $_.RoamingPath
WMIObject = $_
}
}
}
}
}
End {}
}
- Step 2: Delete/Remove user profiles
As you can see in the above Get-UserProfile function, I’ve chosen to return the full WMI instance as property of the final object. We actually need it to delete the user profile as I’ll use the Delete hidden method on the WMI instance that is fully supported on windows Vista and onwards.
Deleting a user profile consists in the following actions:
- Remove the key representing the SID of the user under HKLM\software\microsoft\windows nt\currentversion\profilelist
- Remove the GUID of the user profile under the key HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileGuid’
- Remove the folder of the user under C:\users
Note that whenever you’ve incoherencies between folders under C:\users and the information under HKLM\software\microsoft\windows nt\currentversion\profilelist, you may have users being logged on a temporary profile and have their registry key backed up…
Function Delete-UserProfile
{
[CmdletBinding()]
param
(
[parameter(ValueFromPipeline=$true, Mandatory=$true, Position=0)]
[ValidateNotNullOrEmpty()]
# [PSTypeName('System.Management.ManagementObject#root\cimv2\Win32_UserProfile')]
[System.Management.ManagementObject[]]${WMIObject}
)
Begin{}
Process
{
$WMIObject | ForEach-Object -Process {
$profile = (ConvertTo-NTAccount -SID $_.SID)
try
{
# Invoke-WmiMethod -Name Delete reports that 'This method is not implemented'
# ([wmi]$_.__Path).Delete()
$_.Delete()
} catch {
Write-Warning -Message "Failed to delete profile $profile"
}
}
}
End {}
} # end of function
- Step 3: Assemble all the pieces and build the final report
- Use the above Get-UserProfile and Delete-UserProfile functions
- Write a new Remove-Profile function that accepts the threshold and days parameters and sends an HTML report
Here’s the result 🙂
#Requires -Version 2.0
function Get-FolderSize {
<#
.SYNOPSIS
Gets the size of a folder by getting the size of all files contained within.
.DESCRIPTION
This function uses recursion to get the size of a folder and its subfolders. The output is
a collection of objects that each have a folder name, a size in bytes, and a size in
formatted bytes (converted to kilo, Mega, Giga, etc).
.PARAMETER Path
Path to the folder whose info is needed.
.PARAMETER RecurseLevel
The level under the parent folder to return objects. Negative values mean infinite depth,
so all subfolders will be returned. Zero means that you only want to have an object returned
for the parent folder. See examples for more information.
Default is -1 (Infinite)
.PARAMETER Descending
Because of the nature of the function, the data cannot be sorted in ascending order unless
no objects are returned until after all of the folder information has been obtained. All of
the folder names can be returned in descending order in real time, though.
Default is false, so objects returned have folder information in a semi-ascending order.
.PARAMETER IncludeHidden
Include hidden objects in folder information.
Default is false.
.PARAMETER IncludeReparsePoints
Include reparse points in folder information. This means that sizes may be counted more than
once.
Default is false.
.EXAMPLE
PS C:\> Get-FolderSize -LiteralPath C:\Temp
SizeInBytes Size of Folder Folder
----------- -------------- ------
34288 33.00 kiloBytes C:\temp\R196853\Vi32\Data\Cur
34288 33.00 kiloBytes C:\temp\R196853\Vi32\Data
60393 59.00 kiloBytes C:\temp\R196853\Vi32\Eula
14906422 14.00 MegaBytes C:\temp\R196853\Vi32
34288 33.00 kiloBytes C:\temp\R196853\Vi64\Data\Cur
34288 33.00 kiloBytes C:\temp\R196853\Vi64\Data
60393 59.00 kiloBytes C:\temp\R196853\Vi64\Eula
15772346 15.00 MegaBytes C:\temp\R196853\Vi64
30797949 29.00 MegaBytes C:\temp\R196853
15944 16.00 kiloBytes C:\temp\R252187\Vi32\Eula
16542513 16.00 MegaBytes C:\temp\R252187\Vi32
16626114 16.00 MegaBytes C:\temp\R252187
137289106 131.00 MegaBytes C:\temp
.EXAMPLE
PS C:\> Get-FolderSize -LiteralPath C:\Temp -RecurseLevel 1
SizeInBytes Size of Folder Folder
----------- -------------- ------
30797949 29.00 MegaBytes C:\temp\R196853
34209551 33.00 MegaBytes C:\temp\R252187
65007500 62.00 MegaBytes C:\temp
.EXAMPLE
PS C:\> Get-FolderSize -LiteralPath C:\Temp -RecurseLevel 0
SizeInBytes Size of Folder Folder
----------- -------------- ------
65007500 62.00 MegaBytes C:\temp
.EXAMPLE
PS C:\> Get-FolderSize -LiteralPath C:\Temp -Descending
SizeInBytes Size of Folder Folder
----------- -------------- ------
73775 72.00 kiloBytes C:\temp\R252187\Vi64\Eula
17525606 17.00 MegaBytes C:\temp\R252187\Vi64
73775 72.00 kiloBytes C:\temp\R252187\Vi32\Eula
16600344 16.00 MegaBytes C:\temp\R252187\Vi32
34209551 33.00 MegaBytes C:\temp\R252187
60393 59.00 kiloBytes C:\temp\R196853\Vi64\Eula
34288 33.00 kiloBytes C:\temp\R196853\Vi64\Data\Cur
34288 33.00 kiloBytes C:\temp\R196853\Vi64\Data
15772346 15.00 MegaBytes C:\temp\R196853\Vi64
60393 59.00 kiloBytes C:\temp\R196853\Vi32\Eula
34288 33.00 kiloBytes C:\temp\R196853\Vi32\Data\Cur
34288 33.00 kiloBytes C:\temp\R196853\Vi32\Data
14906422 14.00 MegaBytes C:\temp\R196853\Vi32
30797949 29.00 MegaBytes C:\temp\R196853
65007500 62.00 MegaBytes C:\temp
.EXAMPLE
PS C:\> Get-FolderSize -LiteralPath C:\Temp -IncludeHidden -IncludeReparsePoints
If there were hidden files/folders or reparse points in C:\Temp, they would be counted in the size
totals.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory=$true, Position=0)]
[Alias("Path")]
[System.IO.DirectoryInfo] $LiteralPath,
[Parameter(Mandatory=$false, Position=1)]
[int] $RecurseLevel = -1,
[Parameter(Mandatory=$false)]
[switch] $Descending = $false,
[Parameter(Mandatory=$false)]
[switch] $IncludeHidden = $false,
[Parameter(Mandatory=$false)]
[switch] $IncludeReparsePoints = $false
)
Write-Debug "In $LiteralPath"
Write-Verbose "In $LiteralPath"
# Initialize variable that keeps up with folder size
$Size = 0
# $RecurseLevel and $WriteToPipeline control whether or not an object
# is written to the pipeline. See below when recursive call is made
# for more info.
$WriteToPipeline = $true
if ($RecurseLevel -eq 0) {
# Do not write any more objects to pipeline. Recursive calls still
# need to be made in order to get size of subfolders, though
$WriteToPipeline = $false
}
elseif ($RecurseLevel -gt 0) {
# Keep $WriteToPipeline equal to $true, but decrement the RecurseLevel
# for future calls
$RecurseLevel--
}
# List all child items in current path. Notice that $IncludeHidden controls whether
# -Force parameter is used.
Get-ChildItem -LiteralPath $LiteralPath -Force:$IncludeHidden -ErrorAction SilentlyContinue |
Sort-Object -Property FullName -Descending:$Descending | # Sort either ascending or descending
ForEach-Object {
# Enter if block if we are either including reparse points OR the current file/folder isn't
# a reparse point:
if ($IncludeReparsePoints -or !($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) {
if ($_.PsIsContainer) {
# We've found a sub folder!
# Prepare parameters to pass:
$Parameters = @{ LiteralPath = $_.FullName;
Descending = $Descending;
IncludeHidden = $IncludeHidden;
IncludeReparsePoints = $IncludeReparsePoints;
RecurseLevel = $RecurseLevel
}
# Recursively call on folder size function; tee-object saves the returned object so we
# can get information from it, and forwards it along the pipeline so it can (potentially)
# be written to output
Get-FolderSize @Parameters | Tee-Object -Variable SubFolder | Where-Object {
# This acts as a block if we're not supposed to return an object for this folder.
# Script execution must continue, though, so that we can get the true folder size
# for the original parent folder(s)
$WriteToPipeline
}
# As we get deeper into folder structure, $SubFolder object will contain more than one of
# the custom objects since $SubFolder contains what has been output by all previous subfolder
# calls.
if ($SubFolder.Count) {
# To get around this, simply throw away all objects except the last one (remember,
# all subfolder objects have already been written; this is just the tee'd variable
# we're working with here)
$SubFolder = $SubFolder[-1]
}
# Get the size of the subfolders from the returned objects
$Size += $SubFolder.SizeInBytes
}
else {
# File, so add this to the folder's size
$Size += $_.Length
}
} # End of Reparse point if block
} # End of ForEach-Object
# Create PSObject with folder information:
$ReturnObject = New-Object PSObject -Property @{
Folder = $LiteralPath;
SizeInBytes = $Size;
# "Size of Folder (SI)" = Get-FormattedByte -Bytes $Size -Standard "si";
# "Size of Folder (IEC)" = Get-FormattedByte -Bytes $Size -Standard "iec";
"Size of Folder" = Get-FormattedByte -Bytes $Size -Standard "legacy"
}
# Return object
Write-Output $ReturnObject
}
function Get-FormattedByte {
<#
.SYNOPSIS
Changes raw number of Bytes into a more readable string.
.DESCRIPTION
This function takes a raw number of bytes and outputs a readable string. Example is 2048 Bytes
would be changed to 2 kiloBytes.
.PARAMETER Bytes
Number of bytes that need to be formatted.
.PARAMETER Precision
Number of decimal places to take conversion to. Default is 2.
.PARAMETER Standard
Determines the unit and prefixes used when converting the 'Bytes'. Choose between SI, IEC and
Legacy standards. NOTE: Legacy standard is not an official standard. Legacy is what Windows
operating systems use.
Default is legacy.
.PARAMETER Suffix
A string that will be added to the end of the prefix used in formatted text. Default is 'Bytes'
.EXAMPLE
PS C:\> Get-FormattedByte 2000000000
1.86 GigaBytes
.EXAMPLE
PS C:\> Get-FormattedByte 2000000000 -Standard si
2.00 GigaBytes
PS C:\> Get-FormattedByte 2000000000 -Standard iec
1.86 GibiBytes
PS C:\> Get-FormattedByte 2000000000 -Precision 4
1.8626 GigaBytes
PS C:\> Get-FormattedByte 2000000000 -Precision 4 -Suffix "B"
1.8626 GigaB
#>
param(
[Parameter(Mandatory=$true, Position=0)]
[double] $Bytes,
[Parameter(Mandatory=$false, Position=1)]
[int] $Precision = 2,
[Parameter(Mandatory=$false)]
[ValidateSet("si","iec", "legacy")]
$Standard = "legacy",
[Parameter(Mandatory=$false)]
[string] $Suffix = "Bytes"
)
# International System of Units Standard:
$si = @{ Unit = 1000;
Prefixes = "", "kilo","Mega", "Giga", "Tera", "Peta", "Exa" }
# International Electrotechnial Commision Standard:
$iec = @{ Unit = 1024;
Prefixes = "", "Kibi", "Mebi", "Gibi", "Tebi", "Pebi", "Exbi" }
# Legacy unit/prefix where binary unit uses SI prefixes:
$legacy = @{ Unit = 1024;
Prefixes = "", "kilo","Mega", "Giga", "Tera", "Peta", "Exa" }
# Get the hashtable that contains the prefixes and unit:
$StandardHT = Invoke-Expression "`$$Standard"
foreach ($Prefix in $StandardHT.Prefixes) {
if (($Prefix -eq $StandardHT.Prefixes[-1]) -or ($Bytes -lt $StandardHT.Unit)) {
# Either out of prefixes, in which case we should just say how
# many "whatever" bytes we have, or we have a small enough number
# of "whatever" bytes to display the formatted bytes
return "$($Bytes.ToString(`"F$Precision`")) $($Prefix)$Suffix"
}
else {
# Divide by unit and use a new prefix on next loop
$Bytes /= $StandardHT.Unit
}
}
}
function ConvertTo-NtAccount
{
<#
.SYNOPSIS
Translate a SID to its displayname
.DESCRIPTION
Translate a SID to its displayname
.PARAMETER Sid
Provide a SID
.NOTES
Name: ConvertTo-NtAccount
Author: thepowershellguy
.LINK
http://thepowershellguy.com/blogs/posh/archive/2007/01/23/powershell-converting-accountname-to-sid-and-vice-versa.aspx
.EXAMPLE
ConvertTo-NtAccount S-1-1-0
Convert a well-known SID to its displayname
#>
param(
[parameter(Mandatory=$true,Position=0)][system.string]$Sid = $null
)
begin
{
$obj = new-object system.security.principal.securityidentifier($sid)
}
process
{
try
{
$obj.translate([system.security.principal.ntaccount])
}
catch
{
# To remove the silent fail, uncomment next line
# $_
}
}
End {}
}
Function Remove-Profile {
[cmdletbinding()]
Param (
[parameter(
HelpMessage="Threshold of freespace left expressed in percent")]
[ValidateRange(0,100)]
[int]$FreeSpaceThreshold = 20,
[parameter()]
[int]$Days = 5
)
Begin {
# Define the Html parts of the report
$head = @"
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>HTML TABLE</title>
</head><body>
<table>
"@
$end = "</table></body></html>"
# Not an array as Send-mailmessage doesn't accept a system.object and tries a conversion to system.string that fails
$body = @"
"@
}
Process {
# Define the HKLM Constant and the Key we are looking for
$HKLM = 2147483650
$Key = "software\microsoft\windows nt\currentversion\profilelist"
$result = $null
# Get the location of user profiles from the registry
try {
$result = Invoke-WmiMethod -Path "ROOT\DEFAULT:StdRegProv" -Name GetExpandedStringValue -ArgumentList $HKLM,$Key,"ProfilesDirectory" -ErrorAction Stop
} catch {
Write-Warning -Message "WMI query failed"
}
if ($result.sValue) {
$drive = Split-Path $result.sValue -Qualifier
try {
$wmi_worked = $true
# Get the freespace of the drive where user profiles are located
$disk = Get-WmiObject -Class Win32_LogicalDisk -filter "DeviceID='$drive'" -ErrorAction Stop
} catch {
Write-Warning -Message "Failed to query WMI"
$wmi_worked = $false
}
if ($wmi_worked) {
# Get a nicely formatted of the freespace initially left on the drive
$FreeDividedSize=$disk.Freespace/$disk.Size
[string]$PerFree="{0:P}" -f $FreeDividedSize
# Add the header to the Html report
$body += $head
$body += "<p></p><br>"
# Get the freespace
$body += New-Object -TypeName PSObject -Property @{
'Initial Freespace' = (Get-FormattedByte -Bytes $disk.Freespace -Standard "legacy" -Precision 1)
'Percent of total drive' = $PerFree
} | ConvertTo-Html -Property 'Initial Freespace','Percent of total drive' -Fragment -As LIST
# Calculate the total size of C:\users
$body += New-Object -TypeName PSObject -Property @{
'Location of profiles' = $result.sValue
'Initial size of profiles' = (Get-FolderSize -LiteralPath $result.sValue -RecurseLevel 0 -IncludeHidden:$true ).'Size of Folder'
} | ConvertTo-Html -Property 'Location of profiles','Initial size of profiles' -Fragment -As LIST
$body += "<p></p><br>"
# Define a black list of profiles not to delete
$ExclusionList = 'Default User','All Users','Default',"Public"
# Calculate the size of each profile
$body += Get-ChildItem -Path $result.sValue -Exclude $ExclusionList | Where {$_ -is [system.IO.directoryInfo]} |ForEach-Object -Process {
New-Object -TypeName PSObject -Property @{
'Profile' = $_.FullName
'Size' = (Get-FolderSize -LiteralPath $_.FullName -RecurseLevel -1 -IncludeHidden:$true )[-1].'Size of Folder'
}
} | ConvertTo-Html -Property 'Profile','Size' -Fragment -As TABLE
# Evaluate the threshold
if ($FreeDividedSize -gt $($FreeSpaceThreshold/100)) {
"There's more freespace than the {0:P0} threshold specified" -f ($FreeSpaceThreshold/100)
# We do nothing...
} else {
# We start deleting folders older that the Days specified as parameter
$body += "<p></p><br>"
$body += Get-UserProfile | Where-Object -FilterScript { $_.LastUsedDate -le (Get-Date).AddDays(-$Days) } |
ForEach-Object -Process {
# Remove profiles
$status = 'ok'
Write-Verbose -Message "Attempt to delete profile $($_.UserName) located in $($_.Path)" -Verbose
$_ | Select-Object -ExpandProperty WMIObject | Delete-UserProfile
# If deleting profiles with WMI failed we may still have a folder left
if (Test-Path $_.Path) {
$status = 'failed'
# Attempt to delete this folder, but not with Remove-Item that throws an Access Denied
$cmdcommand = "$env:systemroot\system32\cmd.exe /C `"rd /S/Q " + $_.Path + "`""
$cmdcommandresult = Invoke-Expression $cmdcommand
# Test whether our good old rd command succeeded or failed
if (Test-Path $_.Path) {
$status = 'failed'
Write-Warning -Message "Failed to remove folder $($_.Path)"
} else {
$status = 'ok'
Write-Warning -Message "2nd attempt to remove folder $($_.Path) was successful"
}
}
New-Object -TypeName PSObject -Property @{
'Deleted Profile' = $_.UserName
'Result' = $status
}
} | ConvertTo-Html -Property 'Deleted Profile','Result' -Fragment -As TABLE
# Get the freespace left on the disk after user profiles removals
$disk = Get-WmiObject -Class Win32_LogicalDisk -filter "DeviceID='$drive'"
$FreeDividedSize=$disk.Freespace/$disk.Size
[string]$PerFree="{0:P}" -f $FreeDividedSize
$body += "<p></p><br>"
$body += New-Object -TypeName PSObject -Property @{
'Final Freespace' = (Get-FormattedByte -Bytes $disk.Freespace -Standard "legacy" -Precision 1)
'Percent of total drive' = $PerFree
} | ConvertTo-Html -Property 'Final Freespace','Percent of total drive' -Fragment -As LIST
# Get the total size of profiles after removal
$body += New-Object -TypeName PSObject -Property @{
'Final size of profiles' = (Get-FolderSize -LiteralPath $result.sValue -RecurseLevel 0 -IncludeHidden:$true ).'Size of Folder'
} | ConvertTo-Html -Property 'Final size of profiles' -Fragment -As LIST
$body += "<p></p><br>"
# Get profiles left after the removal
$body += Get-ChildItem -Path $result.sValue | Where {$_ -is [system.IO.directoryInfo]} |ForEach-Object -Process {
New-Object -TypeName PSObject -Property @{
'Profile' = $_.FullName
'Size' = (Get-FolderSize -LiteralPath $_.FullName -RecurseLevel -1 -IncludeHidden:$true )[-1].'Size of Folder'
}
} | ConvertTo-Html -Property 'Profile','Size' -Fragment -As TABLE
}
$body += $end
# Send the final report
$smtpserver = "the.smtp.server.address"
$from = "$env:computername@$(($env:USERDNSDOMAIN).ToLower())"
$to = "the.admin.email.address"
$Subject = "Freespace operation"
try {
Send-MailMessage -Body $body -From $from -Subject $Subject -To $to -SmtpServer $smtpserver -BodyAsHtml -ErrorAction Stop
} catch {
Write-Warning -Message "Failed to send the final report because $($_.Exception.Message)"
}
}
}
}
End {}
} # end of function
# Main
Remove-Profile
The only issue I’ve encountered so far with the above script is that the Tee-Object cmdlet that Rohn Edwards uses in his advanced functions fails with the following error message – Tee-Object : The pipeline failed due to call depth overflow. The call depth reached 51 and the maximum is 50. – when used in powershell remote session