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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
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.
    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