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