Skip to content

Instantly share code, notes, and snippets.

@contactbrenton
Last active July 15, 2026 12:52
Show Gist options
  • Select an option

  • Save contactbrenton/1706c611202d74896f36cfc5319933ad to your computer and use it in GitHub Desktop.

Select an option

Save contactbrenton/1706c611202d74896f36cfc5319933ad to your computer and use it in GitHub Desktop.
Downloads attachments from a Microsoft 365 Outlook mailbox using Microsoft Graph.
#requires -Version 7.0
<#
.SYNOPSIS
Downloads attachments from a Microsoft 365 Outlook mailbox using Microsoft Graph.
.DESCRIPTION
- Reads messages across the primary mailbox, not only the Inbox.
- Accepts date filters only in ISO 8601 format: yyyy-MM-dd, or a full timestamp with Z/offset.
- Parses Graph receivedDateTime values as culture-independent ISO 8601 and uses the local time zone for output folders.
- Downloads file attachments and Outlook item attachments.
- Saves reference/cloud attachments as JSON metadata because Graph cannot download
their raw content through the attachment /$value endpoint (returns HTTP 405).
- Excludes inline attachments such as signature logos unless -IncludeInline is used.
- Creates one folder per source message and writes a CSV manifest.
- Does not modify, move, mark as read, or delete any email.
- Does not alter downloaded attachment timestamps, attributes, ACLs, or other file properties after download.
- Requests immutable IDs (Prefer: IdType="ImmutableId") so message and attachment
IDs stay stable when items are moved between folders, keeping re-run
skip-existing behaviour reliable.
- Neutralises CSV formula injection in the manifest: attacker-controlled values
(subject, sender, attachment names) are prefixed with a single quote when they
start with =, +, -, @, tab, or CR so they cannot execute as formulas in Excel.
- Sanitises attachment file names, including Unicode direction-override
characters and Windows reserved device names.
Interactive sign-in requires delegated Mail.Read and Mail.Read.Shared permissions.
Unattended sign-in requires an Entra application with application Mail.Read permission
and a certificate available in the local certificate store.
Note for Windows: very long subjects or attachment names can approach the classic
260-character path limit. The script shortens names where it can; enabling Windows
long path support (LongPathsEnabled) is recommended for deep output folders.
Exit codes: 0 = success, 2 = completed with one or more failed attachments.
.EXAMPLE
./Export-OutlookMailboxAttachments.ps1 `
-Mailbox accounts@contoso.com `
-OutputPath C:\MailboxExports\Accounts
.EXAMPLE
./Export-OutlookMailboxAttachments.ps1 `
-Mailbox accounts@contoso.com `
-OutputPath C:\MailboxExports\Accounts `
-ReceivedAfter '2025-01-01' `
-UseDeviceCode
.EXAMPLE
./Export-OutlookMailboxAttachments.ps1 `
-Mailbox accounts@contoso.com `
-OutputPath C:\MailboxExports\Accounts `
-TenantId '00000000-0000-0000-0000-000000000000' `
-ClientId '11111111-1111-1111-1111-111111111111' `
-CertificateThumbprint 'ABCDEF1234567890ABCDEF1234567890ABCDEF12'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$Mailbox,
[Parameter()]
[string]$OutputPath,
# Date filters are strings and are accepted only in ISO 8601 format.
# Accepted values: yyyy-MM-dd, or a full timestamp with an explicit Z/offset.
[Parameter()]
[string]$ReceivedAfter,
[Parameter()]
[string]$ReceivedBefore,
[Parameter()]
[switch]$IncludeInline,
[Parameter()]
[switch]$Overwrite,
[Parameter()]
[switch]$UseDeviceCode,
[Parameter()]
[string]$TenantId,
[Parameter()]
[string]$ClientId,
[Parameter()]
[string]$CertificateThumbprint,
[Parameter()]
[ValidateRange(1, 1000)]
[int]$PageSize = 250,
[Parameter()]
[ValidateRange(1, 12)]
[int]$MaxRetries = 6,
[Parameter()]
[ValidateRange(30, 3600)]
[int]$RequestTimeoutSeconds = 180
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# Invoke-MgGraphRequest emits PowerShell web-request progress records while
# writing files. Some hosts render these as repeated
# 'WriteRequestProgressActivity' text and blank lines rather than a progress
# bar. Suppress that stream and provide clear download messages instead.
$script:OriginalProgressPreference = $ProgressPreference
$ProgressPreference = 'SilentlyContinue'
# Immutable IDs keep message/attachment IDs stable when items move between
# folders, so folder names and skip-existing checks survive re-runs.
# https://learn.microsoft.com/graph/outlook-immutable-id
$script:GraphRequestHeaders = @{ Prefer = 'IdType="ImmutableId"' }
function ConvertTo-GraphUtcBoundary {
param(
[Parameter(Mandatory)]
[string]$Value,
[Parameter(Mandatory)]
[ValidateSet('ReceivedAfter', 'ReceivedBefore')]
[string]$ParameterName
)
$trimmed = $Value.Trim()
if ([string]::IsNullOrWhiteSpace($trimmed)) {
return $null
}
# Accept only an ISO 8601 date-only value. All culture-specific date formats are rejected.
$parsedDate = [datetime]::MinValue
if ([datetime]::TryParseExact(
$trimmed,
'yyyy-MM-dd',
[System.Globalization.CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::None,
[ref]$parsedDate)) {
$unspecified = [datetime]::SpecifyKind($parsedDate.Date, [System.DateTimeKind]::Unspecified)
$utc = [System.TimeZoneInfo]::ConvertTimeToUtc($unspecified, [System.TimeZoneInfo]::Local)
return [pscustomobject]@{
Utc = $utc
DisplayInput = $parsedDate.ToString('yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture)
TimeZone = [System.TimeZoneInfo]::Local.Id
}
}
# Also accept a full ISO 8601 timestamp only when it contains an explicit
# UTC marker or offset, for example 2025-07-01T00:00:00+10:00 or ...Z.
if ($trimmed -match '^\d{4}-\d{2}-\d{2}T.*(?:Z|[+-]\d{2}:\d{2})$') {
$parsedOffset = [datetimeoffset]::MinValue
if ([datetimeoffset]::TryParseExact(
$trimmed,
@(
'yyyy-MM-ddTHH:mm:ssK',
'yyyy-MM-ddTHH:mm:ss.fffK',
'yyyy-MM-ddTHH:mm:ss.FFFFFFFK'
),
[System.Globalization.CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::None,
[ref]$parsedOffset)) {
return [pscustomobject]@{
Utc = $parsedOffset.UtcDateTime
DisplayInput = $parsedOffset.ToString('o')
TimeZone = 'explicit offset'
}
}
}
throw "-$ParameterName '$Value' is not valid. Use ISO 8601 only: yyyy-MM-dd, or a full timestamp such as 2026-07-15T00:00:00+10:00 or 2026-07-14T14:00:00Z."
}
function ConvertFrom-GraphReceivedDateTime {
param(
[Parameter(Mandatory)]
[AllowNull()]
[object]$Value
)
if ($null -eq $Value) {
throw 'Microsoft Graph returned an empty receivedDateTime value.'
}
if ($Value -is [System.DateTimeOffset]) {
return [System.DateTimeOffset]$Value
}
if ($Value -is [System.DateTime]) {
$dateValue = [System.DateTime]$Value
if ($dateValue.Kind -eq [System.DateTimeKind]::Unspecified) {
# Graph timestamps are UTC/offset timestamps. Treat an unexpectedly
# unqualified DateTime as UTC rather than allowing local culture or
# regional settings to reinterpret it.
$dateValue = [System.DateTime]::SpecifyKind($dateValue, [System.DateTimeKind]::Utc)
}
return [System.DateTimeOffset]::new($dateValue)
}
$text = [System.Convert]::ToString(
$Value,
[System.Globalization.CultureInfo]::InvariantCulture
).Trim()
if ([string]::IsNullOrWhiteSpace($text)) {
throw 'Microsoft Graph returned an empty receivedDateTime value.'
}
try {
# Graph returns receivedDateTime as ISO 8601/RFC 3339. XmlConvert parses
# that format independently of Windows regional settings, so a value
# such as 2026-07-08 can never be interpreted as 7 August.
return [System.Xml.XmlConvert]::ToDateTimeOffset($text)
}
catch {
throw "Microsoft Graph returned an invalid receivedDateTime value '$text'. Expected an ISO 8601 timestamp such as 2026-07-08T03:15:00Z."
}
}
function Get-ObjectValue {
param(
[Parameter()]
[AllowNull()]
[object]$InputObject,
[Parameter(Mandatory)]
[string]$Name
)
if ($null -eq $InputObject) {
return $null
}
if ($InputObject -is [System.Collections.IDictionary]) {
if ($InputObject.Contains($Name)) {
return $InputObject[$Name]
}
return $null
}
$property = $InputObject.PSObject.Properties[$Name]
if ($null -ne $property) {
return $property.Value
}
return $null
}
function Get-ShortHash {
param(
[Parameter(Mandatory)]
[string]$Value,
[Parameter()]
[ValidateRange(6, 64)]
[int]$Length = 12
)
$sha256 = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($Value)
$hashBytes = $sha256.ComputeHash($bytes)
$hex = ([System.BitConverter]::ToString($hashBytes)).Replace('-', '').ToLowerInvariant()
return $hex.Substring(0, [Math]::Min($Length, $hex.Length))
}
finally {
$sha256.Dispose()
}
}
function Get-SafeFileName {
param(
[Parameter()]
[AllowEmptyString()]
[string]$Name,
[Parameter()]
[ValidateRange(20, 220)]
[int]$MaximumLength = 180
)
if ([string]::IsNullOrWhiteSpace($Name)) {
$Name = 'unnamed-attachment'
}
# Use Windows-safe filename rules even when the script runs on another OS.
$safe = $Name -replace '[<>:"/\\|?*\x00-\x1F]', '_'
# Strip Unicode format characters (e.g. right-to-left override U+202E)
# that senders can use to spoof file extensions in Explorer.
$safe = $safe -replace '\p{Cf}', ''
$safe = $safe.Trim().TrimEnd([char[]]@(' ', '.'))
if ([string]::IsNullOrWhiteSpace($safe)) {
$safe = 'unnamed-attachment'
}
# Guard Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
if ([System.IO.Path]::GetFileNameWithoutExtension($safe) -match '^(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') {
$safe = '_' + $safe
}
if ($safe.Length -le $MaximumLength) {
return $safe
}
$extension = [System.IO.Path]::GetExtension($safe)
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($safe)
$availableLength = $MaximumLength - $extension.Length
if ($availableLength -lt 1) {
return $safe.Substring(0, $MaximumLength)
}
return $baseName.Substring(0, [Math]::Min($availableLength, $baseName.Length)) + $extension
}
function Get-ItemAttachmentExtension {
param(
[Parameter()]
[AllowNull()]
[string]$ContentType
)
switch -Regex ($ContentType) {
'^message/rfc822' { return '.eml' }
'^text/calendar' { return '.ics' }
'vcard|x-vcard' { return '.vcf' }
default { return '.mime' }
}
}
function Get-HttpStatusCode {
param(
[Parameter(Mandatory)]
[System.Management.Automation.ErrorRecord]$ErrorRecord
)
$exception = $ErrorRecord.Exception
foreach ($propertyName in @('ResponseStatusCode', 'StatusCode')) {
$property = $exception.PSObject.Properties[$propertyName]
if ($null -ne $property -and $null -ne $property.Value) {
try { return [int]$property.Value } catch { }
}
}
$responseProperty = $exception.PSObject.Properties['Response']
if ($null -ne $responseProperty -and $null -ne $responseProperty.Value) {
$statusProperty = $responseProperty.Value.PSObject.Properties['StatusCode']
if ($null -ne $statusProperty -and $null -ne $statusProperty.Value) {
try { return [int]$statusProperty.Value } catch { }
}
}
if ($exception.Message -match '\b(400|401|403|404|408|409|423|429|500|502|503|504)\b') {
return [int]$Matches[1]
}
return $null
}
function Get-RetryAfterSeconds {
param(
[Parameter(Mandatory)]
[System.Management.Automation.ErrorRecord]$ErrorRecord
)
# Honour the Retry-After header on throttling responses when present.
try {
$responseProperty = $ErrorRecord.Exception.PSObject.Properties['Response']
if ($null -eq $responseProperty -or $null -eq $responseProperty.Value) {
return $null
}
$retryAfter = $responseProperty.Value.Headers.RetryAfter
if ($null -eq $retryAfter) {
return $null
}
# PowerShell unwraps Nullable<T> members, so no .Value on Delta/Date.
if ($null -ne $retryAfter.Delta) {
return [int][Math]::Ceiling($retryAfter.Delta.TotalSeconds)
}
if ($null -ne $retryAfter.Date) {
$seconds = ($retryAfter.Date - [System.DateTimeOffset]::UtcNow).TotalSeconds
return [int][Math]::Max(1, [Math]::Ceiling($seconds))
}
}
catch {
return $null
}
return $null
}
function Test-RetryableFailure {
param(
[Parameter(Mandatory)]
[System.Management.Automation.ErrorRecord]$ErrorRecord,
[Parameter()]
[AllowNull()]
[object]$StatusCode
)
if ($null -ne $StatusCode) {
return [int]$StatusCode -in @(408, 423, 429, 500, 502, 503, 504)
}
# No HTTP status available: retry transport-level failures (DNS, socket
# resets, timeouts) but not local or argument errors.
$exception = $ErrorRecord.Exception
while ($null -ne $exception) {
if ($exception -is [System.Net.Http.HttpRequestException] -or
$exception -is [System.Net.Sockets.SocketException] -or
$exception -is [System.IO.IOException] -or
$exception -is [System.Threading.Tasks.TaskCanceledException] -or
$exception -is [System.TimeoutException]) {
return $true
}
$exception = $exception.InnerException
}
return $false
}
function Get-RetryDelaySeconds {
param(
[Parameter(Mandatory)]
[System.Management.Automation.ErrorRecord]$ErrorRecord,
[Parameter(Mandatory)]
[int]$Attempt
)
$retryAfter = Get-RetryAfterSeconds -ErrorRecord $ErrorRecord
if ($null -ne $retryAfter) {
# Cap so a hostile or broken header cannot stall the run.
return [Math]::Min(300, [Math]::Max(1, $retryAfter))
}
return [Math]::Min(60, [Math]::Pow(2, $Attempt) + (Get-Random -Minimum 0 -Maximum 4))
}
function Invoke-GraphJsonWithRetry {
param(
[Parameter(Mandatory)]
[string]$Uri
)
for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
try {
return Invoke-MgGraphRequest -Method GET -Uri $Uri -Headers $script:GraphRequestHeaders -ErrorAction Stop
}
catch {
$statusCode = Get-HttpStatusCode -ErrorRecord $_
$retryable = Test-RetryableFailure -ErrorRecord $_ -StatusCode $statusCode
if (-not $retryable -or $attempt -eq $MaxRetries) {
throw
}
$delaySeconds = Get-RetryDelaySeconds -ErrorRecord $_ -Attempt $attempt
$statusText = if ($null -ne $statusCode) { "HTTP $statusCode" } else { 'a transport error' }
Write-Warning "Graph request returned $statusText. Retrying in $delaySeconds seconds ($attempt/$MaxRetries)."
Start-Sleep -Seconds $delaySeconds
}
}
}
function Invoke-GraphDownloadWithRetry {
param(
[Parameter(Mandatory)]
[string]$Uri,
[Parameter(Mandatory)]
[string]$Destination
)
for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
try {
if (Test-Path -LiteralPath $Destination) {
Remove-Item -LiteralPath $Destination -Force
}
Invoke-MgGraphRequest `
-Method GET `
-Uri $Uri `
-Headers $script:GraphRequestHeaders `
-OutputFilePath $Destination `
-ErrorAction Stop | Out-Null
return
}
catch {
if (Test-Path -LiteralPath $Destination) {
Remove-Item -LiteralPath $Destination -Force -ErrorAction SilentlyContinue
}
$statusCode = Get-HttpStatusCode -ErrorRecord $_
$retryable = Test-RetryableFailure -ErrorRecord $_ -StatusCode $statusCode
if (-not $retryable -or $attempt -eq $MaxRetries) {
throw
}
$delaySeconds = Get-RetryDelaySeconds -ErrorRecord $_ -Attempt $attempt
$statusText = if ($null -ne $statusCode) { "HTTP $statusCode" } else { 'a transport error' }
Write-Warning "Attachment download returned $statusText. Retrying in $delaySeconds seconds ($attempt/$MaxRetries)."
Start-Sleep -Seconds $delaySeconds
}
}
}
function ConvertTo-CsvCell {
param(
[Parameter()]
[AllowNull()]
[object]$Value
)
if ($null -eq $Value) {
return '""'
}
$text = [string]$Value
# Neutralise CSV formula injection. Subjects, sender addresses, and
# attachment names are attacker-controlled; a value like =cmd|... must
# not execute when the manifest is opened in Excel.
if ($text -match '^[=+\-@\t\r]') {
$text = "'" + $text
}
return '"' + $text.Replace('"', '""') + '"'
}
function Write-ManifestRow {
param(
[Parameter(Mandatory)]
[hashtable]$Row
)
$cells = foreach ($column in $script:ManifestColumns) {
ConvertTo-CsvCell -Value $Row[$column]
}
$script:ManifestWriter.WriteLine(($cells -join ','))
$script:ManifestWriter.Flush()
}
# Validate date parameters before loading modules, authenticating, creating the
# output directory, or making any Microsoft Graph request.
$afterBoundary = $null
$beforeBoundary = $null
if (-not [string]::IsNullOrWhiteSpace($ReceivedAfter)) {
$afterBoundary = ConvertTo-GraphUtcBoundary -Value $ReceivedAfter -ParameterName 'ReceivedAfter'
}
if (-not [string]::IsNullOrWhiteSpace($ReceivedBefore)) {
$beforeBoundary = ConvertTo-GraphUtcBoundary -Value $ReceivedBefore -ParameterName 'ReceivedBefore'
}
if ($null -ne $afterBoundary -and $null -ne $beforeBoundary -and $afterBoundary.Utc -ge $beforeBoundary.Utc) {
throw '-ReceivedAfter must be earlier than -ReceivedBefore.'
}
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph.Authentication)) {
throw @"
Microsoft.Graph.Authentication is not installed.
Install it with:
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
"@
}
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop
# @() around the whole pipeline: Where-Object returns $null when nothing
# matches, and $null.Count throws under StrictMode.
$authenticationValues = @(@($TenantId, $ClientId, $CertificateThumbprint) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($authenticationValues.Count -gt 0 -and $authenticationValues.Count -lt 3) {
throw 'For certificate-based authentication, provide TenantId, ClientId, and CertificateThumbprint together.'
}
if ($UseDeviceCode -and $authenticationValues.Count -gt 0) {
throw '-UseDeviceCode is for delegated sign-in and cannot be combined with TenantId/ClientId/CertificateThumbprint.'
}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$safeMailboxName = Get-SafeFileName -Name ($Mailbox -replace '@', '_at_') -MaximumLength 100
$OutputPath = Join-Path -Path (Get-Location) -ChildPath "MailboxAttachments-$safeMailboxName"
}
$OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
$runTimestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$manifestPath = Join-Path $OutputPath "attachment-manifest-$runTimestamp.csv"
$script:ManifestColumns = @(
'RunTimestamp',
'Mailbox',
'MessageId',
'InternetMessageId',
'ReceivedDateTime',
'Sender',
'Subject',
'AttachmentId',
'AttachmentType',
'AttachmentName',
'ContentType',
'SizeBytes',
'IsInline',
'LocalPath',
'Status',
'Error'
)
$utf8WithBom = [System.Text.UTF8Encoding]::new($true)
$script:ManifestWriter = [System.IO.StreamWriter]::new($manifestPath, $false, $utf8WithBom)
$script:ManifestWriter.WriteLine((($script:ManifestColumns | ForEach-Object { ConvertTo-CsvCell -Value $_ }) -join ','))
$script:ManifestWriter.Flush()
$processedMessages = 0
$downloadedAttachments = 0
$skippedAttachments = 0
$failedAttachments = 0
try {
if ($authenticationValues.Count -eq 3) {
Write-Host 'Connecting to Microsoft Graph using certificate-based app authentication...'
Connect-MgGraph `
-TenantId $TenantId `
-ClientId $ClientId `
-CertificateThumbprint $CertificateThumbprint `
-ContextScope Process `
-NoWelcome
}
else {
Write-Host 'Connecting to Microsoft Graph using delegated authentication...'
$connectParameters = @{
Scopes = @('Mail.Read', 'Mail.Read.Shared')
ContextScope = 'Process'
NoWelcome = $true
}
if ($UseDeviceCode) {
$connectParameters['UseDeviceAuthentication'] = $true
}
Connect-MgGraph @connectParameters
}
# Prevent a single Graph call from waiting indefinitely. The Graph SDK
# also retries requests internally; keep that to one retry because this
# script already has its own controlled retry loop.
if ($null -ne (Get-Command -Name Set-MgRequestContext -ErrorAction SilentlyContinue)) {
Set-MgRequestContext `
-ClientTimeout $RequestTimeoutSeconds `
-MaxRetry 1 `
-ErrorAction Stop | Out-Null
}
$mailboxEncoded = [System.Uri]::EscapeDataString($Mailbox)
$selectFields = 'id,internetMessageId,receivedDateTime,subject,from,hasAttachments,parentFolderId'
$messageUri = "https://graph.microsoft.com/v1.0/users/$mailboxEncoded/messages?`$select=$selectFields&`$top=$PageSize"
$filterParts = [System.Collections.Generic.List[string]]::new()
# hasAttachments does not include messages that only contain inline attachments.
# When inline files are requested, scan every message so these are not missed.
if (-not $IncludeInline) {
$filterParts.Add('hasAttachments eq true')
}
if ($null -ne $afterBoundary) {
$afterUtc = $afterBoundary.Utc.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
$filterParts.Add("receivedDateTime ge $afterUtc")
}
if ($null -ne $beforeBoundary) {
$beforeUtc = $beforeBoundary.Utc.ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
$filterParts.Add("receivedDateTime lt $beforeUtc")
}
if ($filterParts.Count -gt 0) {
$filter = $filterParts -join ' and '
$messageUri += "&`$filter=$([System.Uri]::EscapeDataString($filter))"
}
Write-Host "Mailbox: $Mailbox"
Write-Host "Output: $OutputPath"
Write-Host "Manifest: $manifestPath"
Write-Host "Graph request timeout: $RequestTimeoutSeconds seconds"
Write-Host "Folder date timezone: $([System.TimeZoneInfo]::Local.Id)"
if ($null -ne $afterBoundary) {
Write-Host ("Received after: {0} [{1}] -> {2}" -f $afterBoundary.DisplayInput, $afterBoundary.TimeZone, $afterBoundary.Utc.ToString('yyyy-MM-ddTHH:mm:ssZ'))
}
if ($null -ne $beforeBoundary) {
Write-Host ("Received before: {0} [{1}] -> {2}" -f $beforeBoundary.DisplayInput, $beforeBoundary.TimeZone, $beforeBoundary.Utc.ToString('yyyy-MM-ddTHH:mm:ssZ'))
}
Write-Host ''
$nextMessagePage = $messageUri
while (-not [string]::IsNullOrWhiteSpace($nextMessagePage)) {
$messagePage = Invoke-GraphJsonWithRetry -Uri $nextMessagePage
$messages = @(Get-ObjectValue -InputObject $messagePage -Name 'value')
foreach ($message in $messages) {
if ($null -eq $message) {
continue
}
$processedMessages++
$messageId = [string](Get-ObjectValue -InputObject $message -Name 'id')
if ([string]::IsNullOrWhiteSpace($messageId)) {
continue
}
$internetMessageId = [string](Get-ObjectValue -InputObject $message -Name 'internetMessageId')
$subject = [string](Get-ObjectValue -InputObject $message -Name 'subject')
if ([string]::IsNullOrWhiteSpace($subject)) {
$subject = '(no subject)'
}
$receivedDateTimeValue = Get-ObjectValue -InputObject $message -Name 'receivedDateTime'
try {
$receivedDateTime = ConvertFrom-GraphReceivedDateTime -Value $receivedDateTimeValue
$receivedDateTimeText = $receivedDateTime.ToUniversalTime().ToString(
'o',
[System.Globalization.CultureInfo]::InvariantCulture
)
}
catch {
$failedAttachments++
Write-Warning "Skipping message '$subject' because receivedDateTime could not be parsed: $($_.Exception.Message)"
Write-ManifestRow -Row @{
RunTimestamp = $runTimestamp
Mailbox = $Mailbox
MessageId = $messageId
InternetMessageId = $internetMessageId
ReceivedDateTime = [System.Convert]::ToString($receivedDateTimeValue, [System.Globalization.CultureInfo]::InvariantCulture)
Sender = ''
Subject = $subject
AttachmentId = ''
AttachmentType = ''
AttachmentName = ''
ContentType = ''
SizeBytes = ''
IsInline = ''
LocalPath = ''
Status = 'InvalidReceivedDateTime'
Error = $_.Exception.Message
}
continue
}
# Store folders using the computer's configured local time zone,
# while retaining the original UTC timestamp in the manifest and
# file metadata. This avoids month/year shifts around midnight UTC.
$receivedFolderDateTime = [System.TimeZoneInfo]::ConvertTime(
$receivedDateTime,
[System.TimeZoneInfo]::Local
)
$sender = ''
$from = Get-ObjectValue -InputObject $message -Name 'from'
if ($null -ne $from) {
$emailAddress = Get-ObjectValue -InputObject $from -Name 'emailAddress'
if ($null -ne $emailAddress) {
$sender = [string](Get-ObjectValue -InputObject $emailAddress -Name 'address')
}
}
$safeSubject = Get-SafeFileName -Name $subject -MaximumLength 100
$messageHash = Get-ShortHash -Value $messageId -Length 12
$datePath = Join-Path $OutputPath $receivedFolderDateTime.ToString('yyyy', [System.Globalization.CultureInfo]::InvariantCulture)
$datePath = Join-Path $datePath $receivedFolderDateTime.ToString('MM', [System.Globalization.CultureInfo]::InvariantCulture)
$messageFolderName = '{0}_{1}_{2}' -f $receivedFolderDateTime.ToString('yyyyMMdd-HHmmss', [System.Globalization.CultureInfo]::InvariantCulture), $safeSubject, $messageHash
$messageFolder = Join-Path $datePath $messageFolderName
$messageIdEncoded = [System.Uri]::EscapeDataString($messageId)
$attachmentUri = "https://graph.microsoft.com/v1.0/users/$mailboxEncoded/messages/$messageIdEncoded/attachments?`$select=id,name,contentType,size,isInline"
$nextAttachmentPage = $attachmentUri
while (-not [string]::IsNullOrWhiteSpace($nextAttachmentPage)) {
try {
$attachmentPage = Invoke-GraphJsonWithRetry -Uri $nextAttachmentPage
}
catch {
$failedAttachments++
Write-Warning "Could not list attachments for message '$subject': $($_.Exception.Message)"
Write-ManifestRow -Row @{
RunTimestamp = $runTimestamp
Mailbox = $Mailbox
MessageId = $messageId
InternetMessageId = $internetMessageId
ReceivedDateTime = $receivedDateTimeText
Sender = $sender
Subject = $subject
AttachmentId = ''
AttachmentType = ''
AttachmentName = ''
ContentType = ''
SizeBytes = ''
IsInline = ''
LocalPath = ''
Status = 'FailedToListAttachments'
Error = $_.Exception.Message
}
break
}
$attachments = @(Get-ObjectValue -InputObject $attachmentPage -Name 'value')
foreach ($attachment in $attachments) {
if ($null -eq $attachment) {
continue
}
$attachmentId = [string](Get-ObjectValue -InputObject $attachment -Name 'id')
$attachmentName = [string](Get-ObjectValue -InputObject $attachment -Name 'name')
$contentType = [string](Get-ObjectValue -InputObject $attachment -Name 'contentType')
$sizeBytes = Get-ObjectValue -InputObject $attachment -Name 'size'
$isInlineValue = Get-ObjectValue -InputObject $attachment -Name 'isInline'
$isInline = $false
if ($null -ne $isInlineValue) {
$isInline = [bool]$isInlineValue
}
$attachmentType = [string](Get-ObjectValue -InputObject $attachment -Name '@odata.type')
if ([string]::IsNullOrWhiteSpace($attachmentType)) {
$attachmentType = 'microsoft.graph.attachment'
}
if ($isInline -and -not $IncludeInline) {
$skippedAttachments++
Write-ManifestRow -Row @{
RunTimestamp = $runTimestamp
Mailbox = $Mailbox
MessageId = $messageId
InternetMessageId = $internetMessageId
ReceivedDateTime = $receivedDateTimeText
Sender = $sender
Subject = $subject
AttachmentId = $attachmentId
AttachmentType = $attachmentType
AttachmentName = $attachmentName
ContentType = $contentType
SizeBytes = $sizeBytes
IsInline = $isInline
LocalPath = ''
Status = 'SkippedInline'
Error = ''
}
continue
}
New-Item -ItemType Directory -Path $messageFolder -Force | Out-Null
$safeAttachmentName = Get-SafeFileName -Name $attachmentName -MaximumLength 160
if ($attachmentType -eq '#microsoft.graph.itemAttachment' -and [string]::IsNullOrWhiteSpace([System.IO.Path]::GetExtension($safeAttachmentName))) {
$safeAttachmentName += Get-ItemAttachmentExtension -ContentType $contentType
}
$attachmentHash = Get-ShortHash -Value $attachmentId -Length 10
$localFileName = "${attachmentHash}_$safeAttachmentName"
$localPath = Join-Path $messageFolder $localFileName
# Windows MAX_PATH guard: shorten the attachment name if the
# full path would exceed the classic 260-character limit
# (long path support cannot be assumed to be enabled).
if ($IsWindows -and $localPath.Length -gt 259) {
$availableForName = 259 - ($messageFolder.Length + 1 + $attachmentHash.Length + 1)
if ($availableForName -ge 20) {
$safeAttachmentName = Get-SafeFileName -Name $safeAttachmentName -MaximumLength ([Math]::Min($availableForName, 220))
$localFileName = "${attachmentHash}_$safeAttachmentName"
$localPath = Join-Path $messageFolder $localFileName
}
else {
Write-Warning "Path may exceed the Windows path limit and cannot be shortened further: $localPath"
}
}
$attachmentIdEncoded = [System.Uri]::EscapeDataString($attachmentId)
$singleAttachmentUri = "https://graph.microsoft.com/v1.0/users/$mailboxEncoded/messages/$messageIdEncoded/attachments/$attachmentIdEncoded"
try {
if ($attachmentType -eq '#microsoft.graph.referenceAttachment') {
$referencePath = $localPath + '.reference.json'
if ((Test-Path -LiteralPath $referencePath) -and -not $Overwrite) {
$skippedAttachments++
$status = 'SkippedExisting'
}
else {
$referenceMetadata = Invoke-GraphJsonWithRetry -Uri $singleAttachmentUri
$referenceMetadata | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $referencePath -Encoding utf8
# Do not rewrite file timestamps or other properties after creation.
$downloadedAttachments++
$status = 'SavedReferenceMetadata'
}
Write-ManifestRow -Row @{
RunTimestamp = $runTimestamp
Mailbox = $Mailbox
MessageId = $messageId
InternetMessageId = $internetMessageId
ReceivedDateTime = $receivedDateTimeText
Sender = $sender
Subject = $subject
AttachmentId = $attachmentId
AttachmentType = $attachmentType
AttachmentName = $attachmentName
ContentType = $contentType
SizeBytes = $sizeBytes
IsInline = $isInline
LocalPath = $referencePath
Status = $status
Error = ''
}
continue
}
if ((Test-Path -LiteralPath $localPath) -and -not $Overwrite) {
$skippedAttachments++
$status = 'SkippedExisting'
}
else {
$downloadUri = "$singleAttachmentUri/`$value"
$downloadStarted = Get-Date
$displaySize = if ($null -ne $sizeBytes -and "$sizeBytes" -ne '') {
"$sizeBytes bytes"
}
else {
'unknown size'
}
Write-Host ("Downloading [{0}] {1}" -f $displaySize, $safeAttachmentName)
# Do not set LastWriteTime, CreationTime, attributes, or ACLs.
# Windows/PowerShell will retain the normal properties created by the download operation.
Invoke-GraphDownloadWithRetry -Uri $downloadUri -Destination $localPath
$downloadedAttachments++
$status = 'Downloaded'
$elapsedSeconds = ((Get-Date) - $downloadStarted).TotalSeconds
Write-Host ("Downloaded in {0:N1}s: {1}" -f $elapsedSeconds, $safeAttachmentName)
}
Write-ManifestRow -Row @{
RunTimestamp = $runTimestamp
Mailbox = $Mailbox
MessageId = $messageId
InternetMessageId = $internetMessageId
ReceivedDateTime = $receivedDateTimeText
Sender = $sender
Subject = $subject
AttachmentId = $attachmentId
AttachmentType = $attachmentType
AttachmentName = $attachmentName
ContentType = $contentType
SizeBytes = $sizeBytes
IsInline = $isInline
LocalPath = $localPath
Status = $status
Error = ''
}
}
catch {
$failedAttachments++
Write-Warning "Failed to download '$attachmentName' from '$subject': $($_.Exception.Message)"
Write-ManifestRow -Row @{
RunTimestamp = $runTimestamp
Mailbox = $Mailbox
MessageId = $messageId
InternetMessageId = $internetMessageId
ReceivedDateTime = $receivedDateTimeText
Sender = $sender
Subject = $subject
AttachmentId = $attachmentId
AttachmentType = $attachmentType
AttachmentName = $attachmentName
ContentType = $contentType
SizeBytes = $sizeBytes
IsInline = $isInline
LocalPath = $localPath
Status = 'Failed'
Error = $_.Exception.Message
}
}
}
$nextAttachmentPage = [string](Get-ObjectValue -InputObject $attachmentPage -Name '@odata.nextLink')
}
if (($processedMessages % 100) -eq 0) {
Write-Host "Processed $processedMessages messages; downloaded $downloadedAttachments attachments..."
}
}
$nextMessagePage = [string](Get-ObjectValue -InputObject $messagePage -Name '@odata.nextLink')
}
}
finally {
if ($null -ne $script:ManifestWriter) {
$script:ManifestWriter.Flush()
$script:ManifestWriter.Dispose()
}
try {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
catch { }
$ProgressPreference = $script:OriginalProgressPreference
}
Write-Host ''
Write-Host 'Export complete.'
Write-Host "Messages processed: $processedMessages"
Write-Host "Attachments downloaded: $downloadedAttachments"
Write-Host "Attachments skipped: $skippedAttachments"
Write-Host "Attachments failed: $failedAttachments"
Write-Host "Manifest: $manifestPath"
if ($failedAttachments -gt 0) {
exit 2
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment