From 3585088515c168b3329246e333e1412766c67113 Mon Sep 17 00:00:00 2001 From: SingularVector Date: Sat, 24 Oct 2026 00:46:55 +0200 Subject: [PATCH] Modernize EML parsing with pinned cross-platform MIME support --- .gitattributes | 2 + .github/workflows/eml.yml | 40 ++++++++ .github/workflows/guid.yml | 2 + .github/workflows/registry.yml | 2 + .github/workflows/upn.yml | 2 + .gitignore | 3 + Convert-EmlFile.ps1 | 142 +++++++++++++++++++++-------- Private/Import-EmlMimeKit.ps1 | 29 ++++++ README.md | 6 +- Tests/Convert-EmlFile.Tests.ps1 | 132 +++++++++++++++++++++++++++ Tests/Fixtures/Eml/header-only.eml | 3 + Tests/Fixtures/Eml/invalid.eml | 2 + Tests/Fixtures/Eml/latin1.eml | 6 ++ Tests/Fixtures/Eml/multipart.eml | 41 +++++++++ Tests/Fixtures/Eml/plain.eml | 14 +++ docs/Convert-EmlFile.md | 73 +++++++++++++++ scripts/Install-EmlDependency.ps1 | 40 ++++++++ 17 files changed, 500 insertions(+), 39 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/eml.yml create mode 100644 Private/Import-EmlMimeKit.ps1 create mode 100644 Tests/Convert-EmlFile.Tests.ps1 create mode 100644 Tests/Fixtures/Eml/header-only.eml create mode 100644 Tests/Fixtures/Eml/invalid.eml create mode 100644 Tests/Fixtures/Eml/latin1.eml create mode 100644 Tests/Fixtures/Eml/multipart.eml create mode 100644 Tests/Fixtures/Eml/plain.eml create mode 100644 docs/Convert-EmlFile.md create mode 100644 scripts/Install-EmlDependency.ps1 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

+--alt-- +--outer +Content-Type: application/octet-stream; name="../danger.txt" +Content-Disposition: attachment; filename="../danger.txt" +Content-Transfer-Encoding: base64 + +c2FtcGxl +--outer +Content-Type: image/png +Content-Disposition: inline; filename="image.png" +Content-Id: +Content-Transfer-Encoding: base64 + +aW1hZ2U= +--outer +Content-Type: message/rfc822 +Content-Disposition: attachment; filename="forwarded.eml" + +From: nested@example.com +To: receiver@example.com +Subject: Nested message + +Nested body +--outer-- diff --git a/Tests/Fixtures/Eml/plain.eml b/Tests/Fixtures/Eml/plain.eml new file mode 100644 index 0000000..fa39c22 --- /dev/null +++ b/Tests/Fixtures/Eml/plain.eml @@ -0,0 +1,14 @@ +From: =?utf-8?Q?J=C3=B6rg?= +To: Team: Alice , Bob ; +Cc: copy@example.com +Reply-To: replies@example.com +Subject: =?utf-8?Q?caf=C3=A9?= +Date: Sat, 24 Oct 2026 10:30:00 +0200 +Message-Id: +X-Trace: first +X-Trace: second +MIME-Version: 1.0 +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: base64 + +Y2Fmw6kK diff --git a/docs/Convert-EmlFile.md b/docs/Convert-EmlFile.md new file mode 100644 index 0000000..2f3a49d --- /dev/null +++ b/docs/Convert-EmlFile.md @@ -0,0 +1,73 @@ +# Convert-EmlFile: cross-platform MIME parsing + +## Install once + +Requires PowerShell 7.4+ (.NET 8+) and MimeKitLite 4.18.1. No IT-ToolBox installation is required; that module is not modified. MimeKitLite is the upstream MimeKit parser without most cryptography features. The net8.0 asset has no NuGet dependencies. + +From the repository, in PowerShell: + +```powershell +./scripts/Install-EmlDependency.ps1 +. ./Convert-EmlFile.ps1 +$message = Convert-EmlFile -EmlFileName './message.eml' +$message.Subject +$message.To +$message.Attachments +``` + +Only the explicit installer uses the network. It downloads a pinned NuGet package over HTTPS, checks its SHA-256, and extracts the net8.0 assembly. The original package is retained with its upstream MIT licensing metadata. Files go under ignored .eml-dependencies/MimeKitLite/4.18.1/. Nothing is installed globally. WhatIf performs no writes/downloads. A new custom destination can be selected with Destination; pass its DLL to Convert-EmlFile through MimeKitPath. + +Keep Private/Import-EmlMimeKit.ps1 with the root function. Both package and DLL hashes are pinned; different releases require an intentional dependency update, updated hashes and regression tests. A conflicting MimeKit assembly in the current process produces an error: restart PowerShell rather than trying to replace a loaded assembly. Package 4.18.1 identifies its assembly version as 4.18.0.0, so file checksums, not that version string, identify the dependency. + +## Output schema + +One detached PSCustomObject per input file, with type name StandaloneFunctions.EmlMessage: + +| Property | Meaning | +|---|---| +| SourcePath | Absolute source filename | +| MessageId | Parsed Message-Id, without enclosing angle brackets | +| Subject | Decoded subject, or null if absent | +| Date | DateTimeOffset if a usable Date header is parsed; otherwise null | +| From, To, Cc, Bcc, ReplyTo | Arrays of Name/Address objects; mailbox groups are flattened and group labels omitted | +| TextBody, HtmlBody | MimeKit-selected decoded body strings, or null; not all alternative body parts | +| Headers | Ordered Field/Value records, retaining repeated headers; not a byte-preserving raw header copy | +| Attachments | Metadata for attachment-disposition entities and inline resources: FileName, MimeType, ContentId, IsInline, IsAttachedMessage | + +Attachment metadata contains no raw parser objects, streams, decoded byte arrays or extracted files. Inline resources with Content-ID and no disposition are included when not TextPart entities. Attached message/rfc822 parts are identified as attached messages, not recursively normalized into additional output messages. Names are untrusted original metadata, not safe output filenames. No attachment extraction is provided. + +No HTML is rendered or sanitized and remote resources are not fetched. No S/MIME/OpenPGP decryption or signature verification is performed. Bodies and metadata remain untrusted message content. + +## Errors and limits + +Literal input paths avoid wildcard expansion; strings, arrays and FileInfo inputs are supported. Missing files, directories, nonfilesystem inputs, zero-length files, files exceeding MaxFileSizeBytes, dependency mismatches and parser errors terminate the invocation. Earlier results in a multi-file invocation may already have been emitted when a later file fails. + +MaxFileSizeBytes defaults to 25 MiB and can be set up to 1 GiB. The check is applied to the opened stream before parsing. It limits input bytes, not total allocations or CPU time; this is not a hostile-input sandbox. MimeKit is intentionally tolerant of imperfect email syntax. Headerless input is rejected, but successful parsing is not full RFC validation and not every malformed multipart message will fail. + +The input stream and MimeMessage are disposed in finally; the result remains usable after source deletion. No persistent parsing mode is used. + +## Migration from COM + +The historical function returned a live CDO.Message and needed Windows ADODB/CDO COM objects. This implementation deliberately changes that contract: + +- Use TextBody and HtmlBody instead of CDO.TextBody/HTMLBody assumptions (PowerShell casing is insensitive, but object shape is new). +- Recipient properties are arrays of records rather than COM address-list strings. +- Headers is an ordered record array, not the CDO.Fields collection. +- Attachments supplies metadata, not COM attachment objects or an extraction API. +- There is no Send method, DataSource, native MIME object or byte-preserving Save operation on the result. + +Retain the old implementation on a separate legacy branch if a caller needs its CDO-specific behavior; do not silently mix both contracts. Update consumers and inspect representative real messages before deployment. IT-ToolBox has no equivalent exported function to synchronize here. + +## Tests and CI + +Tests parse actual synthetic MIME fixtures: encoded UTF-8 headers and base64 body, Latin-1 quoted-printable body, multipart text/HTML, inline resources, binary attachment metadata and attached messages. They also cover repeated headers, literal paths, detached results and file-handle release, empty/malformed/missing/oversized inputs and dependency checks. Network calls are mocked in installer failure/WhatIf tests; no real mailboxes are accessed. CI explicitly installs the pinned dependency before the root test suites on Windows/Linux/macOS. Existing root-suite workflows are updated to do the same so they do not fail for a missing parser dependency. + +```powershell +Import-Module Pester -RequiredVersion 5.7.1 +Invoke-Pester ./Tests -Output Detailed +``` + +References: +https://www.nuget.org/packages/MimeKitLite/4.18.1 +https://github.com/jstedfast/MimeKit +https://mimekit.net/docs/html/T_MimeKit_MimeMessage.htm diff --git a/scripts/Install-EmlDependency.ps1 b/scripts/Install-EmlDependency.ps1 new file mode 100644 index 0000000..547cdd0 --- /dev/null +++ b/scripts/Install-EmlDependency.ps1 @@ -0,0 +1,40 @@ +#Requires -Version 7.4 +[CmdletBinding(SupportsShouldProcess)] +param ( + [string]$Destination = (Join-Path $PSScriptRoot '../.eml-dependencies/MimeKitLite/4.18.1') +) + +$ErrorActionPreference = 'Stop' +$uri = 'https://api.nuget.org/v3-flatcontainer/mimekitlite/4.18.1/mimekitlite.4.18.1.nupkg' +$expectedHash = '6cc85bb8dfcd3680226082dbf79cd0ac27442ead35a8e75aa1be7b80acc8f28c' +$destinationPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Destination) +if (-not $PSCmdlet.ShouldProcess($destinationPath, 'Download and install pinned MimeKitLite 4.18.1 from NuGet')) { return } +$temp = Join-Path ([System.IO.Path]::GetTempPath()) ('eml-dependency-' + [guid]::NewGuid().ToString('N')) +try +{ + [void][System.IO.Directory]::CreateDirectory($temp) + $package = Join-Path $temp 'mimekitlite.nupkg' + Invoke-WebRequest -Uri $uri -OutFile $package -ErrorAction Stop + if ((Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash -ine $expectedHash) + { + throw 'MimeKitLite package checksum mismatch; nothing was installed.' + } + $archive = [System.IO.Compression.ZipFile]::OpenRead($package) + try + { + $entry = $archive.GetEntry('lib/net8.0/MimeKitLite.dll') + if ($null -eq $entry) { throw 'Expected net8.0 assembly is absent from package.' } + $staged = Join-Path $temp 'MimeKitLite.dll' + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $staged) + } + finally { $archive.Dispose() } + [void][System.IO.Directory]::CreateDirectory($destinationPath) + Copy-Item -LiteralPath $staged -Destination (Join-Path $destinationPath 'MimeKitLite.dll') -Force + # Retain the original NuGet archive, including its nuspec and upstream licensing metadata. + Copy-Item -LiteralPath $package -Destination (Join-Path $destinationPath 'MimeKitLite.4.18.1.nupkg') -Force + Write-Output (Join-Path $destinationPath 'MimeKitLite.dll') +} +finally +{ + if (Test-Path -LiteralPath $temp) { Remove-Item -LiteralPath $temp -Recurse -Force } +}