diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ccc7da8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Preserve MIME fixtures byte-for-byte, including CRLF header separators. +Tests/Fixtures/Eml/*.eml binary diff --git a/.github/workflows/eml.yml b/.github/workflows/eml.yml new file mode 100644 index 0000000..7befbff --- /dev/null +++ b/.github/workflows/eml.yml @@ -0,0 +1,40 @@ +name: EML parser + +on: + push: + branches: [master] + paths: ['Convert-EmlFile.ps1', 'Private/Import-EmlMimeKit.ps1', 'scripts/Install-EmlDependency.ps1', 'Tests/Convert-EmlFile.Tests.ps1', 'Tests/Fixtures/Eml/**', '.github/workflows/eml.yml'] + pull_request: + paths: ['Convert-EmlFile.ps1', 'Private/Import-EmlMimeKit.ps1', 'scripts/Install-EmlDependency.ps1', 'Tests/Convert-EmlFile.Tests.ps1', 'Tests/Fixtures/Eml/**', '.github/workflows/eml.yml'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + defaults: + run: + shell: pwsh + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Install EML parser dependency + run: ./scripts/Install-EmlDependency.ps1 + - name: Install and run Pester + run: | + $ErrorActionPreference = 'Stop' + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force -Repository PSGallery + Import-Module Pester -RequiredVersion 5.7.1 -Force + $configuration = New-PesterConfiguration + $configuration.Run.Path = './Tests' + $configuration.Run.Exit = $true + $configuration.Output.Verbosity = 'Detailed' + Invoke-Pester -Configuration $configuration diff --git a/.github/workflows/guid.yml b/.github/workflows/guid.yml index e15a539..31d5792 100644 --- a/.github/workflows/guid.yml +++ b/.github/workflows/guid.yml @@ -26,6 +26,8 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Install EML parser dependency + run: ./scripts/Install-EmlDependency.ps1 - name: Install and run Pester run: | $ErrorActionPreference = 'Stop' diff --git a/.github/workflows/registry.yml b/.github/workflows/registry.yml index 91be334..afaa57d 100644 --- a/.github/workflows/registry.yml +++ b/.github/workflows/registry.yml @@ -26,6 +26,8 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Install EML parser dependency + run: ./scripts/Install-EmlDependency.ps1 - name: Install and run Pester run: | $ErrorActionPreference = 'Stop' diff --git a/.github/workflows/upn.yml b/.github/workflows/upn.yml index 0935c07..8d7223e 100644 --- a/.github/workflows/upn.yml +++ b/.github/workflows/upn.yml @@ -26,6 +26,8 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false + - name: Install EML parser dependency + run: ./scripts/Install-EmlDependency.ps1 - name: Install and run Pester run: | $ErrorActionPreference = 'Stop' diff --git a/.gitignore b/.gitignore index df2610d..048eb4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ *.TempPoint.ps1 + +# Locally installed EML parser dependency +.eml-dependencies/ diff --git a/Convert-EmlFile.ps1 b/Convert-EmlFile.ps1 index 2bbb674..9fccd11 100644 --- a/Convert-EmlFile.ps1 +++ b/Convert-EmlFile.ps1 @@ -1,45 +1,111 @@ -function Convert-EmlFile +#Requires -Version 7.4 + +. (Join-Path $PSScriptRoot 'Private/Import-EmlMimeKit.ps1') +$script:EmlDefaultAssemblyPath = Join-Path $PSScriptRoot '.eml-dependencies/MimeKitLite/4.18.1/MimeKitLite.dll' + +function Convert-EmlFile { -<# + <# .SYNOPSIS - Function will parse an eml files. - + Parses local MIME messages into detached PowerShell objects. .DESCRIPTION - Function will parse eml file and return a normalized object that can be used to extract infromation from the encoded file. - - .PARAMETER EmlFileName - A string representing the eml file to parse. - - .EXAMPLE - PS C:\> Convert-EmlFile -EmlFileName 'C:\Test\test.eml' - - .OUTPUTS - System.Object -#> + Uses a pinned MimeKitLite dependency installed explicitly, never downloaded + during parsing. Returns decoded bodies, mailbox lists, repeated headers and + attachment metadata. Does not extract attachments, render HTML, validate + signatures, decrypt content or return live parser/COM objects. + File and parser errors terminate. MaxFileSizeBytes bounds input size only. + #> [CmdletBinding()] - [OutputType([object])] - param - ( - [Parameter(Mandatory = $true)] + [OutputType([pscustomobject])] + param ( + [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] + [Alias('FullName')] [ValidateNotNullOrEmpty()] - [string] - $EmlFileName - ) + [string[]]$EmlFileName, - # Instantiate new ADODB Stream object - $adoStream = New-Object -ComObject 'ADODB.Stream' - - # Open stream - $adoStream.Open() - - # Load file - $adoStream.LoadFromFile($EmlFileName) - - # Instantiate new CDO Message Object - $cdoMessageObject = New-Object -ComObject 'CDO.Message' - - # Open object and pass stream - $cdoMessageObject.DataSource.OpenObject($adoStream, '_Stream') + [ValidateNotNullOrEmpty()] + [string]$MimeKitPath = $script:EmlDefaultAssemblyPath, - return $cdoMessageObject -} \ No newline at end of file + [ValidateRange(1, 1073741824)] + [long]$MaxFileSizeBytes = 26214400 + ) + begin { Import-EmlMimeKit -AssemblyPath $MimeKitPath } + process + { + foreach ($fileName in $EmlFileName) + { + $file = Get-Item -LiteralPath $fileName -ErrorAction Stop + if ($file.PSProvider.Name -ne 'FileSystem' -or $file.PSIsContainer) + { + throw "EML input must be a filesystem file: $fileName" + } + $stream = $null + $message = $null + try + { + $stream = [System.IO.File]::Open($file.FullName, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) + if ($stream.Length -eq 0 -or $stream.Length -gt $MaxFileSizeBytes) + { + throw [System.IO.InvalidDataException]::new("EML input must be nonempty and at most $MaxFileSizeBytes bytes: $($file.FullName)") + } + $message = [MimeKit.MimeMessage]::Load($stream, $false, [System.Threading.CancellationToken]::None) + if ($message.Headers.Count -eq 0) + { + throw [System.FormatException]::new('EML input contains no parsed message headers.') + } + $mailboxes = @{} + foreach ($field in @('From', 'To', 'Cc', 'Bcc', 'ReplyTo')) + { + $mailboxes[$field] = @($message.$field.Mailboxes | ForEach-Object { + [pscustomobject]@{ Name = [string]$_.Name; Address = [string]$_.Address } + }) + } + $attachments = @( + foreach ($part in $message.BodyParts) + { + $isAttachment = $part.IsAttachment + $disposition = $part.ContentDisposition + $isInline = ($null -ne $disposition -and $disposition.Disposition -eq 'inline') -or + (-not $isAttachment -and -not [string]::IsNullOrEmpty($part.ContentId) -and $part -isnot [MimeKit.TextPart]) + if (-not $isAttachment -and -not $isInline) { continue } + $attachmentName = if ($null -ne $disposition) { $disposition.FileName } else { $null } + if ([string]::IsNullOrEmpty($attachmentName)) { $attachmentName = $part.ContentType.Name } + [pscustomobject]@{ + FileName = $attachmentName + MimeType = [string]$part.ContentType.MimeType + ContentId = $part.ContentId + IsInline = [bool]$isInline + IsAttachedMessage = [bool]($part -is [MimeKit.MessagePart]) + } + } + ) + $date = $null + if ($message.Headers.Contains('Date') -and $message.Date -ne [DateTimeOffset]::MinValue) + { + $date = $message.Date + } + [pscustomobject]@{ + PSTypeName = 'StandaloneFunctions.EmlMessage' + SourcePath = $file.FullName + MessageId = $message.MessageId + Subject = $message.Subject + Date = $date + From = $mailboxes.From + To = $mailboxes.To + Cc = $mailboxes.Cc + Bcc = $mailboxes.Bcc + ReplyTo = $mailboxes.ReplyTo + TextBody = $message.TextBody + HtmlBody = $message.HtmlBody + Headers = @($message.Headers | ForEach-Object { [pscustomobject]@{ Field = [string]$_.Field; Value = [string]$_.Value } }) + Attachments = $attachments + } + } + finally + { + if ($null -ne $message) { $message.Dispose() } + if ($null -ne $stream) { $stream.Dispose() } + } + } + } +} diff --git a/Private/Import-EmlMimeKit.ps1 b/Private/Import-EmlMimeKit.ps1 new file mode 100644 index 0000000..648bdc9 --- /dev/null +++ b/Private/Import-EmlMimeKit.ps1 @@ -0,0 +1,29 @@ +function Import-EmlMimeKit +{ + [CmdletBinding()] + param ([Parameter(Mandatory)][string]$AssemblyPath) + + $expectedHash = '437939ba9f01228cc19dd9f755d5e77cac1fd44a8c2b076ed35b927aa625306c' + if (-not (Test-Path -LiteralPath $AssemblyPath -PathType Leaf)) + { + throw 'MimeKitLite dependency not found. Run ./scripts/Install-EmlDependency.ps1 once, or supply MimeKitPath. Parsing never downloads dependencies.' + } + $path = (Get-Item -LiteralPath $AssemblyPath -Force -ErrorAction Stop).FullName + if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -ine $expectedHash) + { + throw 'Unexpected MimeKitLite binary. Use the pinned net8.0 assembly installed by Install-EmlDependency.ps1.' + } + $loadedType = 'MimeKit.MimeMessage' -as [type] + if ($null -ne $loadedType) + { + $assembly = $loadedType.Assembly + if ($assembly.GetName().Name -ne 'MimeKitLite' -or + [string]::IsNullOrWhiteSpace($assembly.Location) -or + (Get-FileHash -LiteralPath $assembly.Location -Algorithm SHA256).Hash -ine $expectedHash) + { + throw 'A conflicting MimeKit assembly is already loaded. Start a clean PowerShell process for this pinned parser.' + } + return + } + [void][System.Reflection.Assembly]::LoadFrom($path) +} diff --git a/README.md b/README.md index b213d05..182b54d 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ The standalone OAuth-style request helper now follows the IT-ToolBox implementat `Get-UniqueUPN` and `Test-UPNExist` now use explicit forest/domain lookup scope, escaped LDAP equality filters, bounded collision checks and independent name formatting. They require PowerShell 7.4; actual lookups additionally require the ActiveDirectory module and directory connectivity. See [usage and compatibility notes](./docs/UPN.md). These are availability checks, not atomic reservations. +### Convert-EmlFile + +Cross-platform MIME parsing now replaces the Windows-only COM result with detached message objects. Requires PowerShell 7.4 and explicit installation of pinned MimeKitLite: `./scripts/Install-EmlDependency.ps1`. Parsing itself never downloads dependencies. See [output schema and migration notes](./docs/Convert-EmlFile.md), especially changes from CDO.Message. + ## Standalone functions The repository also contains standalone utilities including: @@ -46,7 +50,7 @@ The repository also contains standalone utilities including: - [`Get-TimerStatus`](./Get-TimerStatus.ps1) — returns the running state of a stopwatch - [`New-ApiRequest`](./New-ApiRequest.ps1) — helper for API calls using OAuth2 authentication - [`Test-IsGuid`](./Test-IsGuid.ps1) — validates GUID strings -- [`Convert-EmlFile`](./Convert-EmlFile.ps1) — converts `.eml` files into PowerShell objects +- [`Convert-EmlFile`](./Convert-EmlFile.ps1) — parses local MIME messages into detached PowerShell objects - [`New-StringConversion`](./New-StringConversion.ps1) — handles selected string/character conversion scenarios - [`Test-IsRegistryKey`](./Test-IsRegistryKey.ps1) — validates registry keys for use by `Export-Registry` - [`Export-Registry`](./Export-Registry.ps1) — exports registry data to CSV or XML diff --git a/Tests/Convert-EmlFile.Tests.ps1 b/Tests/Convert-EmlFile.Tests.ps1 new file mode 100644 index 0000000..c7df573 --- /dev/null +++ b/Tests/Convert-EmlFile.Tests.ps1 @@ -0,0 +1,132 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '../Convert-EmlFile.ps1') + $fixtures = Join-Path $PSScriptRoot 'Fixtures/Eml' +} +Describe 'EML normalization with real MimeKitLite parsing' { + It 'decodes headers, mailbox groups and text content' { + $r=Convert-EmlFile (Join-Path $fixtures 'plain.eml') + $r.Subject | Should -BeExactly 'café' + $r.From.Count | Should -Be 1 + $r.From[0].Name | Should -BeExactly 'Jörg' + $r.From[0].Address | Should -BeExactly 'sender@example.com' + $r.To.Count | Should -Be 2 + $r.Cc.Count | Should -Be 1 + $r.Bcc.Count | Should -Be 0 + $r.ReplyTo[0].Address | Should -BeExactly 'replies@example.com' + $r.TextBody.Trim() | Should -BeExactly 'café' + $r.MessageId | Should -BeExactly 'plain@example.com' + $r.Date | Should -BeOfType ([DateTimeOffset]) + $r.Date.Offset.TotalHours | Should -Be 2 + } + It 'works under StrictMode with absent optional fields' { + Set-StrictMode -Version Latest + $r=Convert-EmlFile (Join-Path $fixtures 'plain.eml') + $r.Attachments.Count | Should -Be 0 + $r.Subject | Should -BeExactly 'café' + } + It 'preserves repeated headers in sequence' { + $r=Convert-EmlFile (Join-Path $fixtures 'plain.eml') + $values=@($r.Headers | Where-Object Field -eq 'X-Trace' | ForEach-Object Value) + $values.Count | Should -Be 2 + ($values -join ',') | Should -BeExactly 'first,second' + } + It 'returns empty arrays and missing-body/date nulls for a header-only message' { + $r=Convert-EmlFile (Join-Path $fixtures 'header-only.eml') + $r.From.Count | Should -Be 0 + $r.Attachments.Count | Should -Be 0 + $r.Date | Should -BeNullOrEmpty + $r.TextBody | Should -BeNullOrEmpty + $r.HtmlBody | Should -BeNullOrEmpty + } + It 'decodes an older charset and quoted-printable body' { + (Convert-EmlFile (Join-Path $fixtures 'latin1.eml')).TextBody.Trim() | Should -BeExactly 'café' + } + It 'handles multipart bodies, inline metadata and an attached message' { + $r=Convert-EmlFile (Join-Path $fixtures 'multipart.eml') + $r.TextBody | Should -Match 'Plain body' + $r.HtmlBody | Should -Match '
HTML body
' + $r.Attachments.Count | Should -Be 3 + ($r.Attachments | Where-Object FileName -eq '../danger.txt').MimeType | Should -BeExactly 'application/octet-stream' + ($r.Attachments | Where-Object IsInline).ContentId | Should -BeExactly 'image-one' + ($r.Attachments | Where-Object IsAttachedMessage).FileName | Should -BeExactly 'forwarded.eml' + } + It 'includes inline Content-ID parts without a disposition header' { + $path=Join-Path $TestDrive 'cid.eml' + $text=[System.IO.File]::ReadAllText((Join-Path $fixtures 'multipart.eml')) + $text=$text -replace '(?m)^Content-Disposition: inline; filename="image.png"\r?\n', '' + [System.IO.File]::WriteAllText($path,$text) + $r=Convert-EmlFile $path + $inline=@($r.Attachments | Where-Object IsInline) + $inline.Count | Should -Be 1 + $inline[0].ContentId | Should -BeExactly 'image-one' + $inline[0].FileName | Should -BeNullOrEmpty + } + It 'does not extract attachments or render/fetch HTML' { + Mock Invoke-WebRequest {throw 'Parsing must not make network calls'} + $sandbox=New-Item -ItemType Directory -Path (Join-Path $TestDrive 'no-extraction') + Push-Location $sandbox.FullName + try { + $null=Convert-EmlFile (Join-Path $fixtures 'multipart.eml') + @(Get-ChildItem -Force $sandbox.FullName).Count | Should -Be 0 + Should -Invoke Invoke-WebRequest -Times 0 -Exactly + } finally {Pop-Location} + } + It 'returns detached data that survives source deletion and serializes' { + $path=Join-Path $TestDrive 'copy.eml' + Copy-Item (Join-Path $fixtures 'multipart.eml') $path + $r=Convert-EmlFile $path + # FileShare.None would fail on Windows if the source stream was left open. + $stream=[System.IO.File]::Open($path,[System.IO.FileMode]::Open,[System.IO.FileAccess]::ReadWrite,[System.IO.FileShare]::None) + $stream.Dispose() + Remove-Item $path + $r.TextBody | Should -Match 'Plain body' + $r.Attachments[0].GetType().Name | Should -BeExactly 'PSCustomObject' + ($r | ConvertTo-Json -Depth 10) | Should -Match 'forwarded.eml' + } + It 'uses literal filenames and returns one message per pipeline record' { + $path=Join-Path $TestDrive 'mail[1].eml' + Copy-Item (Join-Path $fixtures 'plain.eml') -Destination $path + $rows=@(@($path,(Join-Path $fixtures 'header-only.eml')) | Convert-EmlFile) + $rows.Count | Should -Be 2 + $rows[0].SourcePath | Should -BeExactly $path + } + It 'supports FileInfo input through its FullName property' { + $r=Get-Item (Join-Path $fixtures 'plain.eml') | Convert-EmlFile + $r.Subject | Should -BeExactly 'café' + } + It 'rejects missing files, directories, zero-length and headerless input' { + {Convert-EmlFile (Join-Path $TestDrive 'missing.eml')} | Should -Throw + {Convert-EmlFile $TestDrive} | Should -Throw '*filesystem file*' + $empty=Join-Path $TestDrive 'empty.eml';[System.IO.File]::WriteAllBytes($empty,[byte[]]@()) + {Convert-EmlFile $empty} | Should -Throw '*nonempty*' + {Convert-EmlFile (Join-Path $fixtures 'invalid.eml')} | Should -Throw + } + It 'honors the input-size boundary' { + $path=Join-Path $fixtures 'plain.eml';$size=(Get-Item $path).Length + {Convert-EmlFile $path -MaxFileSizeBytes ($size-1)} | Should -Throw '*at most*' + (Convert-EmlFile $path -MaxFileSizeBytes $size).Subject | Should -BeExactly 'café' + {Convert-EmlFile $path -MaxFileSizeBytes 0} | Should -Throw + } + It 'reports missing or unexpected binaries without downloading them' { + Mock Invoke-WebRequest {throw 'No implicit downloads'} + {Convert-EmlFile (Join-Path $fixtures 'plain.eml') -MimeKitPath (Join-Path $TestDrive 'missing.dll')} | Should -Throw '*dependency not found*' + $fake=Join-Path $TestDrive 'wrong.dll';Set-Content $fake 'not a DLL' + {Convert-EmlFile (Join-Path $fixtures 'plain.eml') -MimeKitPath $fake} | Should -Throw '*Unexpected MimeKitLite binary*' + Should -Invoke Invoke-WebRequest -Times 0 -Exactly + } +} +Describe 'Dependency installer behavior' { + It 'supports WhatIf without downloading or creating directories' { + Mock Invoke-WebRequest {throw 'No download expected'} + $path=Join-Path $TestDrive 'not-installed' + & (Join-Path $PSScriptRoot '../scripts/Install-EmlDependency.ps1') -Destination $path -WhatIf + Test-Path $path | Should -BeFalse + Should -Invoke Invoke-WebRequest -Times 0 -Exactly + } + It 'rejects altered packages before installation' { + Mock Invoke-WebRequest {Set-Content -LiteralPath $OutFile 'corrupt package'} + $path=Join-Path $TestDrive 'bad-install' + {& (Join-Path $PSScriptRoot '../scripts/Install-EmlDependency.ps1') -Destination $path} | Should -Throw '*checksum mismatch*' + Test-Path $path | Should -BeFalse + } +} diff --git a/Tests/Fixtures/Eml/header-only.eml b/Tests/Fixtures/Eml/header-only.eml new file mode 100644 index 0000000..f1b6bd0 --- /dev/null +++ b/Tests/Fixtures/Eml/header-only.eml @@ -0,0 +1,3 @@ +Subject: Headers only +X-Test: value + diff --git a/Tests/Fixtures/Eml/invalid.eml b/Tests/Fixtures/Eml/invalid.eml new file mode 100644 index 0000000..69f4c77 --- /dev/null +++ b/Tests/Fixtures/Eml/invalid.eml @@ -0,0 +1,2 @@ +Not an email message +No field delimiters here diff --git a/Tests/Fixtures/Eml/latin1.eml b/Tests/Fixtures/Eml/latin1.eml new file mode 100644 index 0000000..a28a71f --- /dev/null +++ b/Tests/Fixtures/Eml/latin1.eml @@ -0,0 +1,6 @@ +From: sender@example.com +Subject: Latin charset +Content-Type: text/plain; charset=iso-8859-1 +Content-Transfer-Encoding: quoted-printable + +caf=E9 diff --git a/Tests/Fixtures/Eml/multipart.eml b/Tests/Fixtures/Eml/multipart.eml new file mode 100644 index 0000000..6d0c93e --- /dev/null +++ b/Tests/Fixtures/Eml/multipart.eml @@ -0,0 +1,41 @@ +From: sender@example.com +To: receiver@example.com +Subject: Multipart sample +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="outer" + +--outer +Content-Type: multipart/alternative; boundary="alt" + +--alt +Content-Type: text/plain; charset=utf-8 + +Plain body +--alt +Content-Type: text/html; charset=utf-8 + +HTML body