Use PowerShell to save o365 email Attachments and delete the attachment from the message but don’t delete the message itself.

In this blog I describe creating a PowerShell Function to save email attachments to a local folder while keeping the mail message itself in your mailbox.

Problem Statement:

Like most of us I receive a lot of email every day. Some important, some not. Many that have attachments.
I want to automate saving my email attachments to a local folder then delete only the attachment and retain the message. I’d like to modify the email text to indicate I’ve saved and deleted the attachment.

Required PowerShell Modules:

  • I tested this using latest Windows PowerShell 5.1
  • No external modules required.

Required .Net classes:

Solution:

I investigated use of Microsoft Power Automate and discovered it is possible to save email attachments. I could not find a way to then delete the attachments, retain the email message, and modify the message text to indicate what was done. So I looked to PowerShell and the above referenced DLL.

Test before use!

No really
Test before use!!

12/27/2020 – added recursion to check subfolders. Use the -includeSubFldrs parameter.
12/28/2020 – corrected bug that prevented files from synch with OneDrive
01/04/2021 – added attachment name to the body text.

Function save-Attachment {
    <#
    .SYNOPSIS
    Save attachments to drive and delete attachments from email.
        This function accepts 4 parameters.
    PSCredential:
    Create using the userid/passsword you use for email,
    where username is your email address.
    Inbox Folder DisplayName:
    Saves any email attachment for all email
    in that folder to your OneDrive. It then removes
    the attachment(s) from the email and adds a text
    string indicating the attachment(s) were saved.
    includeSubFolders:
    $true or $false, default is $false. If $true will recurse all subfolders 
    Save Folder Name:
    The root folder to save the attachements to.
    DLL Path:
    Requires use of this .Net DLL. This is a MS managed Open Source project.
    https://github.com/officedev/ews-managed-api/
    https://www.nuget.org/packages/Microsoft.Exchange.WebServices/
    Does not require that Outlook be installed or any other MS Exchange products
    .Example
    PS C:\>$cred = Import-Clixml -Path $env:HOMEPATH\mailCred
    PS C:\>$fnames = @('pstest1','pstest2')
    PS C:\>save-Attachment -credential $cred -folderNames $fnames -Verbose
    .Example
    PS C:$cred = Import-Clixml -Path $env:HOMEPATH\mailCred
    PS C:$fnames = 'pstest2'
    PS C:\>save-Attachment -credential $cred -folderNames $fnames -includeSubFldrs $true
    #>
        [CmdletBinding()]  
        Param (
            [Parameter(Mandatory=$True)]
            [PSCredential]$credential,
            [Parameter(Mandatory=$True)]
            [String[]]$folderNames,
            [Parameter(Mandatory=$False)]
            [bool]$includeSubFldrs = $false,
            [Parameter(Mandatory=$False)]
            [String]$attachmentRoot = "$env:HOMEPATH\OneDrive\out\attachments",
            [Parameter(Mandatory=$False)]
            [String]$dllpath =  "C:\Program Files\Microsoft\Exchange\Web Services\2.2\Microsoft.Exchange.WebServices.dll"
        ) 

        Begin { # Start of the Begin block.
            Write-Verbose -Message "Entering the BEGIN block [$($MyInvocation.MyCommand.CommandType): $($MyInvocation.MyCommand.Name)]."
            [void][Reflection.Assembly]::LoadFile($dllpath)
            $scriptName = 'save-Attachments'
            # Create the directory structure to hold attachment files 
            $fdate = Get-Date -Format "yyyy-MM-dd"
            $fulloutpath = $attachmentRoot + '\' + $fdate.Split('-')[0] + '\' + $fdate.Split('-')[1] +'\'+ $fdate.Split('-')[2] +'\'
            # does not overwrite any existing folders or files
            New-Item -Path $fulloutpath -ItemType Directory -Force
            # Create ExchangeCredential object from PSCredential. The file was created by using Export-Clixml of the PSCred    
            # set the event log source. Use -ErrorAction SilentlyContinue if already exists
            New-EventLog -LogName "Application" -Source "CustomScripts" -ErrorAction SilentlyContinue
            Write-EventLog -LogName Application -Source "CustomScripts" -EventId 1000 -EntryType Information -Message "$scriptName Started"
            # Create ExchangeCredential object from PSCredential. The file was created by using Export-Clixml of the PSCred
            $ExchangeCredential = New-Object Microsoft.Exchange.WebServices.Data.WebCredentials($Credential.Username,
                $Credential.GetNetworkCredential().Password, $Credential.GetNetworkCredential().Domain)
            # Get the email address 
            $mail = $Credential.Username
            # Initialize the ExchangeSerice object 
            $service = new-object Microsoft.Exchange.WebServices.Data.ExchangeService    
            $Service.Credentials = $ExchangeCredential
            $TestUrlCallback = {
                param ([string] $url)
                if ($url -eq "https://autodiscover-s.outlook.com/autodiscover/autodiscover.xml") {
                $true} else {$false}
            }
            $service.AutodiscoverUrl($mail,$TestUrlCallback)
            $itemFilter = New-Object Microsoft.Exchange.WebServices.Data.SearchFilter+IsEqualTo([Microsoft.Exchange.WebServices.Data.EmailMessageSchema]::HasAttachments, $true)
            $itemView = New-Object Microsoft.Exchange.WebServices.Data.ItemView(10000)

        } # End Begin block

        Process {   # Start of Process block.
            Write-Verbose -Message "Entering the PROCESS block [$($MyInvocation.MyCommand.CommandType): $($MyInvocation.MyCommand.Name)]."
            $PropertySet = New-Object Microsoft.Exchange.WebServices.Data.PropertySet([Microsoft.Exchange.WebServices.Data.BasePropertySet]::FirstClassProperties)
            $PropertySet.RequestedBodyType = [Microsoft.Exchange.WebServices.Data.BodyType]::Text
            $fv = new-object Microsoft.Exchange.WebServices.Data.FolderView(20)
            $fv.Traversal = "Deep"
            Foreach($f in $folderNames) {
                $fname = $f
                $ffname = new-object Microsoft.Exchange.WebServices.Data.SearchFilter+ContainsSubstring([Microsoft.Exchange.WebServices.Data.FolderSchema]::DisplayName,"$fName")
                $folders = $service.findFolders([Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::MsgFolderRoot,$ffname, $fv)
                $fid = ($folders | Where-Object{$_.Displayname -eq $fname}).ID.UniqueID
                #Convert the string into type needed by findfolders
                $fuid = [Microsoft.Exchange.WebServices.Data.FolderId]::new($fid)
                if($includeSubFldrs) {
                    $subfolders = $service.findFolders($fuid,$fv)
                    $folders += $subFolders
                }
            }    
                foreach($folder in $folders) {
                    Write-Verbose "Folder: $($folder.DisplayName)"
                Write-EventLog -LogName Application -Source "CustomScripts" -EventId 1000 -EntryType Information -Message "Processing this email folder: $($folder.displayName)"
                $items = $folder.FindItems($itemFilter, $itemView)
                Write-EventLog -LogName Application -Source "CustomScripts" -EventId 1000 -EntryType Information -Message "$($folder.displayName) email with attachments count:$($items.Count)"
                foreach ($item in $items) {
                    Write-Verbose "Subject: $($item.Subject)"
                    $item.Load($PropertySet)
                    # using For loop to avoid enum errors that happen when changing the collection of a Foreach loop.
                    Write-Verbose "Attachments Count: $($item.Attachments.Count)"
                    for($i = 0; $i -lt $($item.Attachments.Count);  ) {
                        $item.Attachments[$i].load()
                        #$attachment.Load()
                        $attachmentname = $item.attachments[$i].Name.ToString()
                        Write-Verbose "Attachment:$attachmentname"
                        #leading and trailing spaces in filenames not allowed in OneDrive
                        $attachmentname = $attachmentname.trim()
                        $attachmentname = $attachmentname -replace '[_,\s]', ''
                        #Onedrive doesn't support these characters in file names.
                        $attachmentname = $attachmentname -replace '["*:<>?/\|]','-'
                        $savePath = "$fulloutpath$attachmentname"
                        $file = New-Object System.IO.FileStream(("$savePath"), [System.IO.FileMode]::Create)
                        $contentlength = $item.attachments[$i].Content.Length
                        Write-Verbose "ContentLength:$contentlength"	
                        $file.Write($item.attachments[$i].Content, 0, $ContentLength)
                        $file.Close()
                        $item.Attachments.Remove($item.attachments[$i])
                    }
                    $bodyText= $item.Body.toString()
                    $newBodyText = "-> $attachmentname saved to $fulloutpath <- `r `n $bodyText" 
                    Write-Verbose $newBodyText     
                    $item.Body.Text = "$newBodyText"      
                    $item.Update([Microsoft.Exchange.WebServices.Data.ConflictResolutionMode]::AutoResolve)
                    Write-EventLog -LogName Application -Source "CustomScripts" -EventId 1000 -EntryType Information -Message "Email attachments removed from $($item.Subject)"
                    }
        }

    } # End of the process block
    End {
        # Start of END block.
        Write-Verbose -Message "Entering the END block [$($MyInvocation.MyCommand.CommandType): $($MyInvocation.MyCommand.Name)]."
        Write-EventLog -LogName Application -Source "CustomScripts" -EventId 1000 -EntryType Information -Message "$scriptName - Exiting"
    } # End of the END Block.


} # End save-attachment function        

#Example:
# Create ExchangeCredential object from PSCredential. The file was created by using Export-Clixml of the PSCred
# When created this way only the user that created it can Import it. Create using the userid/passsword you use for email.
# where username is your email address.
$cred = Import-Clixml -Path $env:HOMEPATH\mailCred
$fnames = @('InBox')
save-Attachment -credential $cred -folderNames $fnames -Verbose -includeSubFldrs $true