Skip to content

Instantly share code, notes, and snippets.

@eugrus
Created July 3, 2026 16:19
Show Gist options
  • Select an option

  • Save eugrus/12e670129a6e41bf498d5ac2df84d71d to your computer and use it in GitHub Desktop.

Select an option

Save eugrus/12e670129a6e41bf498d5ac2df84d71d to your computer and use it in GitHub Desktop.
param(
[string]$Path = '.',
[switch]$WhatIf
)
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 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 -Filter *.msg | ForEach-Object {
if ($_.Name -match '^\d{4}-\d{2}-\d{2} - ') { return }
try {
$date = [datetime]::MinValue; $sender = ''; $subject = ''
[MsgReader]::GetInfo($_.FullName, [ref]$date, [ref]$sender, [ref]$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 + '.msg'
$i = 2
while (Test-Path -LiteralPath (Join-Path $_.DirectoryName $newName)) {
if ($newName -eq $_.Name) { return } # bereits korrekt benannt
$newName = '{0} ({1}).msg' -f $baseName, $i
$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