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 ) $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 |