Last active
July 15, 2026 12:52
-
-
Save contactbrenton/1706c611202d74896f36cfc5319933ad to your computer and use it in GitHub Desktop.
Downloads attachments from a Microsoft 365 Outlook mailbox using Microsoft Graph.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #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