Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Preserve MIME fixtures byte-for-byte, including CRLF header separators.
Tests/Fixtures/Eml/*.eml binary
40 changes: 40 additions & 0 deletions .github/workflows/eml.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions .github/workflows/guid.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/registry.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/upn.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
*.TempPoint.ps1

# Locally installed EML parser dependency
.eml-dependencies/
142 changes: 104 additions & 38 deletions Convert-EmlFile.ps1
Original file line number Diff line number Diff line change
@@ -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
}
[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() }
}
}
}
}
29 changes: 29 additions & 0 deletions Private/Import-EmlMimeKit.ps1
Original file line number Diff line number Diff line change
@@ -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)
}
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
Loading
Loading