Skip to content

Instantly share code, notes, and snippets.

@eugrus
Last active July 14, 2026 15:44
Show Gist options
  • Select an option

  • Save eugrus/707ec3e494271d2c0fdbb593e9558e2a to your computer and use it in GitHub Desktop.

Select an option

Save eugrus/707ec3e494271d2c0fdbb593e9558e2a to your computer and use it in GitHub Desktop.
Normalize filenames of .eml and .msg to date, sender, title
param(
[string]$Path = '.',
[switch]$WhatIf,
[switch]$Force
)
if (-not ('MsgReader' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Text;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
public static class MsgReader
{
[DllImport("ole32.dll")]
private static extern int StgOpenStorage(
[MarshalAs(UnmanagedType.LPWStr)] string pwcsName,
IStorage pstgPriority, uint grfMode, IntPtr snbExclude,
uint reserved, out IStorage ppstgOpen);
[ComImport, Guid("0000000B-0000-0000-C000-000000000046"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IStorage
{
void CreateStream(string pwcsName, uint grfMode, uint r1, uint r2, out IStream ppstm);
void OpenStream(string pwcsName, IntPtr r1, uint grfMode, uint r2, out IStream ppstm);
void CreateStorage(string pwcsName, uint grfMode, uint r1, uint r2, out IStorage ppstg);
void OpenStorage(string pwcsName, IStorage pstgPriority, uint grfMode, IntPtr snbExclude, uint reserved, out IStorage ppstg);
void CopyTo(uint ciidExclude, IntPtr rgiidExclude, IntPtr snbExclude, IStorage pstgDest);
void MoveElementTo(string pwcsName, IStorage pstgDest, string pwcsNewName, uint grfFlags);
void Commit(uint grfCommitFlags);
void Revert();
void EnumElements(uint r1, IntPtr r2, uint r3, out IntPtr ppenum);
void DestroyElement(string pwcsName);
void RenameElement(string pwcsOldName, string pwcsNewName);
void SetElementTimes(string pwcsName, IntPtr pctime, IntPtr patime, IntPtr pmtime);
void SetClass(ref Guid clsid);
void SetStateBits(uint grfStateBits, uint grfMask);
void Stat(out System.Runtime.InteropServices.ComTypes.STATSTG pstatstg, uint grfStatFlag);
}
private const uint STGM_READ = 0x00000000;
private const uint STGM_SHARE_DENY_WRITE = 0x00000020;
private const uint STGM_SHARE_EXCLUSIVE = 0x00000010;
private static byte[] ReadStream(IStorage stg, string name)
{
IStream stm;
try { stg.OpenStream(name, IntPtr.Zero, STGM_READ | STGM_SHARE_EXCLUSIVE, 0, out stm); }
catch { return null; }
try
{
System.Runtime.InteropServices.ComTypes.STATSTG st;
stm.Stat(out st, 1);
byte[] buf = new byte[(int)st.cbSize];
stm.Read(buf, buf.Length, IntPtr.Zero);
return buf;
}
finally { Marshal.ReleaseComObject(stm); }
}
private static string ReadStringProp(IStorage stg, string propId)
{
byte[] b = ReadStream(stg, "__substg1.0_" + propId + "001F"); // PT_UNICODE
if (b != null) return Encoding.Unicode.GetString(b).TrimEnd('\0');
b = ReadStream(stg, "__substg1.0_" + propId + "001E"); // PT_STRING8
if (b != null) return Encoding.Default.GetString(b).TrimEnd('\0');
return null;
}
private static string SenderFromTransportHeaders(IStorage stg)
{
string headers = ReadStringProp(stg, "007D");
if (String.IsNullOrEmpty(headers)) return null;
// Header entfalten (Fortsetzungszeilen beginnen mit Whitespace)
headers = headers.Replace("\r\n ", " ").Replace("\r\n\t", " ");
foreach (string line in headers.Split(new[] { "\r\n" }, StringSplitOptions.None))
{
if (!line.StartsWith("From:", StringComparison.OrdinalIgnoreCase)) continue;
string v = line.Substring(5).Trim();
int lt = v.LastIndexOf('<');
int gt = v.LastIndexOf('>');
if (lt >= 0 && gt > lt) return v.Substring(lt + 1, gt - lt - 1).Trim();
if (v.Contains("@")) return v.Trim('"', ' ');
break;
}
return null;
}
private static bool Plausible(long ft)
{
if (ft <= 0) return false;
DateTime dt = DateTime.FromFileTimeUtc(ft);
return dt.Year >= 1990 && dt.Year <= 2199;
}
public static void GetInfo(string path, out DateTime date, out string sender, out string subject)
{
IStorage stg;
int hr = StgOpenStorage(path, null, STGM_READ | STGM_SHARE_DENY_WRITE, IntPtr.Zero, 0, out stg);
if (hr != 0) Marshal.ThrowExceptionForHR(hr);
try
{
byte[] props = ReadStream(stg, "__properties_version1.0");
if (props == null) throw new InvalidOperationException("Properties stream missing.");
long submit = 0, delivery = 0, creation = 0;
for (int off = 32; off + 16 <= props.Length; off += 16)
{
uint tag = BitConverter.ToUInt32(props, off);
long val = BitConverter.ToInt64(props, off + 8);
if (tag == 0x00390040) submit = val;
else if (tag == 0x0E060040) delivery = val;
else if (tag == 0x30070040) creation = val;
}
long ft = Plausible(submit) ? submit : (Plausible(delivery) ? delivery : creation);
if (!Plausible(ft)) throw new InvalidOperationException("No usable date property found.");
date = DateTime.FromFileTimeUtc(ft).ToLocalTime();
// Absender-Kaskade: SMTP-Properties -> Transport-Header -> Anzeigename
sender = ReadStringProp(stg, "5D01"); // PidTagSenderSmtpAddress
if (String.IsNullOrEmpty(sender))
{
string s = ReadStringProp(stg, "0C1F"); // PR_SENDER_EMAIL_ADDRESS
if (s != null && s.Contains("@")) sender = s;
}
if (String.IsNullOrEmpty(sender))
sender = ReadStringProp(stg, "5D02"); // PidTagSentRepresentingSmtpAddress
if (String.IsNullOrEmpty(sender))
{
string s = ReadStringProp(stg, "0065"); // PR_SENT_REPRESENTING_EMAIL_ADDRESS
if (s != null && s.Contains("@")) sender = s;
}
if (String.IsNullOrEmpty(sender))
sender = SenderFromTransportHeaders(stg); // From:-Header, echter SMTP-Weg
if (String.IsNullOrEmpty(sender))
sender = ReadStringProp(stg, "0C1A"); // Anzeigename als letzter Ausweg
if (String.IsNullOrEmpty(sender)) sender = "unknown";
subject = ReadStringProp(stg, "0037"); // PR_SUBJECT
if (String.IsNullOrEmpty(subject)) subject = "no subject";
}
finally { Marshal.ReleaseComObject(stg); }
}
}
'@
}
function ConvertFrom-EncodedWord {
# RFC-2047-Decodierung: =?charset?B|Q?...?=
param([string]$Text)
if (-not $Text) { return $Text }
$rx = [regex]'=\?([^?]+)\?([BbQq])\?([^?]*)\?='
$out = $rx.Replace($Text, {
param($m)
try {
$enc = [System.Text.Encoding]::GetEncoding($m.Groups[1].Value)
if ($m.Groups[2].Value -match '[Bb]') {
return $enc.GetString([Convert]::FromBase64String($m.Groups[3].Value))
}
# Q-Encoding: _ = Leerzeichen, =XX = Hexbyte
$q = $m.Groups[3].Value -replace '_', ' '
$bytes = New-Object System.Collections.Generic.List[byte]
for ($i = 0; $i -lt $q.Length; $i++) {
if ($q[$i] -eq '=' -and $i + 2 -lt $q.Length) {
$bytes.Add([Convert]::ToByte($q.Substring($i + 1, 2), 16))
$i += 2
} else {
$bytes.AddRange([System.Text.Encoding]::ASCII.GetBytes([string]$q[$i]))
}
}
return $enc.GetString($bytes.ToArray())
} catch { return $m.Value }
})
# Whitespace zwischen zwei encoded-words entfällt laut RFC
return ($out -replace '(?<=\S)\s+(?=\S)', ' ').Trim()
}
function Get-EmlInfo {
param([string]$FilePath)
# Nur den Header-Block lesen (bis zur ersten Leerzeile)
$headerLines = New-Object System.Collections.Generic.List[string]
$reader = [System.IO.StreamReader]::new($FilePath)
try {
while ($null -ne ($line = $reader.ReadLine())) {
if ($line -eq '') { break }
$headerLines.Add($line)
}
}
finally {
$reader.Dispose()
}
# Header entfalten: Fortsetzungszeilen (führender Whitespace) an Vorzeile anhängen
$unfolded = New-Object System.Collections.Generic.List[string]
foreach ($line in $headerLines) {
if ($line -match '^[ \t]' -and $unfolded.Count -gt 0) {
$unfolded[$unfolded.Count - 1] += ' ' + $line.TrimStart()
} else {
$unfolded.Add($line)
}
}
$rawDate = $null; $rawFrom = $null; $rawSubject = $null
foreach ($line in $unfolded) {
if ($null -eq $rawDate -and $line -match '^Date:\s*(.+)$') { $rawDate = $Matches[1] }
elseif ($null -eq $rawFrom -and $line -match '^From:\s*(.+)$') { $rawFrom = $Matches[1] }
elseif ($null -eq $rawSubject -and $line -match '^Subject:\s*(.*)$') { $rawSubject = $Matches[1] }
}
# Datum: RFC-2822-Format, ggf. mit Zonenkommentar wie "(UTC)" am Ende
$date = $null
if ($rawDate) {
$clean = ($rawDate -replace '\s*\([^)]*\)\s*$', '').Trim()
[datetimeoffset]$dto = [datetimeoffset]::MinValue
if ([datetimeoffset]::TryParse($clean,
[System.Globalization.CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::AllowWhiteSpaces,
[ref]$dto)) {
$date = $dto.LocalDateTime
}
}
if (-not $date -or $date.Year -lt 1990) {
$date = (Get-Item -LiteralPath $FilePath).LastWriteTime # Notanker
}
# Absender: Adresse aus spitzen Klammern, sonst der Rohwert, falls er ein @ enthält
$sender = $null
if ($rawFrom) {
$decoded = ConvertFrom-EncodedWord $rawFrom
if ($decoded -match '<([^>]+)>') { $sender = $Matches[1].Trim() }
elseif ($decoded -match '@') { $sender = $decoded.Trim('"', ' ') }
else { $sender = $decoded.Trim() }
}
if (-not $sender) { $sender = 'unknown' }
$subject = if ($rawSubject) { ConvertFrom-EncodedWord $rawSubject } else { $null }
if (-not $subject) { $subject = 'no subject' }
[pscustomobject]@{ Date = $date; Sender = $sender; Subject = $subject }
}
function ConvertTo-SafeFileNamePart {
param([string]$Text, [int]$MaxLength)
$invalid = [System.IO.Path]::GetInvalidFileNameChars() -join ''
$safe = $Text -replace ('[{0}]' -f [regex]::Escape($invalid)), '_'
$safe = ($safe -replace '\s+', ' ').Trim(' ', '.', '_')
if ($safe.Length -gt $MaxLength) { $safe = $safe.Substring(0, $MaxLength).TrimEnd(' ', '.', '_') }
if (-not $safe) { $safe = '_' }
return $safe
}
Get-ChildItem -LiteralPath $Path -File |
Where-Object { $_.Extension -in '.msg', '.eml' } |
ForEach-Object {
if (-not $Force -and $_.Name -match '^\d{4}-\d{2}-\d{2} - ') { return }
try {
$ext = $_.Extension.ToLowerInvariant()
if ($ext -eq '.msg') {
$date = [datetime]::MinValue; $sender = ''; $subject = ''
[MsgReader]::GetInfo($_.FullName, [ref]$date, [ref]$sender, [ref]$subject)
} else {
$info = Get-EmlInfo -FilePath $_.FullName
$date = $info.Date; $sender = $info.Sender; $subject = $info.Subject
}
$senderPart = ConvertTo-SafeFileNamePart -Text $sender -MaxLength 60
$subjectPart = ConvertTo-SafeFileNamePart -Text $subject -MaxLength 100
$baseName = '{0} - {1} - {2}' -f $date.ToString('yyyy-MM-dd'), $senderPart, $subjectPart
$newName = $baseName + $ext
$i = 2
while (Test-Path -LiteralPath (Join-Path $_.DirectoryName $newName)) {
if ($newName -eq $_.Name) { return } # bereits korrekt benannt
$newName = '{0} ({1}){2}' -f $baseName, $i, $ext
$i++
}
if ($WhatIf) {
Write-Host ('{0} -> {1}' -f $_.Name, $newName)
} else {
Rename-Item -LiteralPath $_.FullName -NewName $newName
}
}
catch {
Write-Warning ('{0}: {1}' -f $_.FullName, $PSItem.Exception.Message)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment