#Requires -Version 5.1 Add-Type -AssemblyName System.Windows.Forms, System.Drawing, System.Data [System.Windows.Forms.Application]::EnableVisualStyles() [System.Windows.Forms.Application]::SetCompatibleTextRenderingDefault($false) # ══════════════════════════════════════════════════════════════════════════════ # PARSE-SCRIPTBLOCK — runs in runspace threads, NO UI access # ══════════════════════════════════════════════════════════════════════════════ $script:ParseBlock = { param([string]$Path, [string]$ConnectorFilter, [string]$DateFrom, [string]$DateTo) $hits = [System.Collections.Generic.Dictionary[string,long]]::new(64) $useConnFilter = $ConnectorFilter.Length -gt 0 $useDateFrom = $DateFrom.Length -gt 0 $useDateTo = $DateTo.Length -gt 0 $idxConn = 1 $idxRemote = 5 $idxData = 7 try { $sr = [System.IO.StreamReader]::new($Path, [System.Text.Encoding]::UTF8, $true, 131072) try { while (-not $sr.EndOfStream) { $line = $sr.ReadLine() if ($null -eq $line -or $line.Length -lt 10) { continue } if ($line[0] -eq '#') { if ($line.Length -gt 9 -and $line.Substring(0, 9) -eq '#Fields: ') { $fields = $line.Substring(9).Split(',') for ($i = 0; $i -lt $fields.Length; $i++) { switch ($fields[$i].Trim()) { 'connector-id' { $idxConn = $i } 'remote-endpoint' { $idxRemote = $i } 'data' { $idxData = $i } } } } continue } if ($useDateFrom -or $useDateTo) { $d = $line.Substring(0, 10) if ($useDateFrom -and [string]::CompareOrdinal($d, $DateFrom) -lt 0) { continue } if ($useDateTo -and [string]::CompareOrdinal($d, $DateTo) -gt 0) { continue } } if ($line.IndexOf('EHLO', [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue } $parts = $line.Split(',') $need = [Math]::Max($idxConn, [Math]::Max($idxRemote, $idxData)) if ($parts.Length -le $need) { continue } if ($parts[$idxData].IndexOf('EHLO', [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue } if ($useConnFilter -and $parts[$idxConn].IndexOf($ConnectorFilter, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue } $remote = $parts[$idxRemote] $ip = if ($remote.Length -gt 0 -and $remote[0] -eq '[') { $cb = $remote.IndexOf(']') if ($cb -gt 1) { $remote.Substring(1, $cb - 1) } else { $remote } } else { $ci = $remote.IndexOf(':') if ($ci -gt 0) { $remote.Substring(0, $ci) } else { $remote } } if ($ip.Length -eq 0) { continue } $cur = 0L [void]$hits.TryGetValue($ip, [ref]$cur) $hits[$ip] = $cur + 1L } } finally { $sr.Dispose() } } catch { } return $hits } # ══════════════════════════════════════════════════════════════════════════════ # DNS-SCRIPTBLOCK — runs in runspace threads # ══════════════════════════════════════════════════════════════════════════════ $script:DnsBlock = { param([string]$IP) try { return [PSCustomObject]@{ IP = $IP; Name = [System.Net.Dns]::GetHostEntry($IP).HostName } } catch { return [PSCustomObject]@{ IP = $IP; Name = '-' } } } # ══════════════════════════════════════════════════════════════════════════════ # MSGTRACK-SCRIPTBLOCK — runs in runspace threads # Parses Exchange MessageTracking logs (MSGTRKyyyyMMdd-nnn.LOG) # # Mode: # Server → [0]=SendCount [1]=RecvCount [2]=SendBytes [3]=RecvBytes # Day → [0]=SendCount [1]=RecvCount [2]=SendBytes [3]=RecvBytes # Recipient → [0]=Count # Sender → [0]=Count # # Fields before message-subject (idx 18) are safe with plain CSV split. # Fields at or after message-subject need an offset because subjects can # contain commas (quoted-CSV). $off = parts.Length - totalF # ══════════════════════════════════════════════════════════════════════════════ $script:MsgTrackBlock = { param([string]$Path, [string]$Mode, [string]$DateFrom, [string]$DateTo) $result = [System.Collections.Generic.Dictionary[string,long[]]]::new(64) $useDateFrom = $DateFrom.Length -gt 0 $useDateTo = $DateTo.Length -gt 0 $idxDateTime = 0 $idxServer = 4 $idxEventId = 8 $idxRecipient = 12 $idxBytes = 14 $idxSubject = 18 $idxSender = 19 $totalF = 30 try { $sr = [System.IO.StreamReader]::new($Path, [System.Text.Encoding]::UTF8, $true, 131072) try { while (-not $sr.EndOfStream) { $line = $sr.ReadLine() if ($null -eq $line -or $line.Length -lt 5) { continue } if ($line[0] -eq '#') { if ($line.Length -gt 9 -and $line.Substring(0, 9) -eq '#Fields: ') { $fields = $line.Substring(9).Split(',') $totalF = $fields.Length for ($i = 0; $i -lt $fields.Length; $i++) { switch ($fields[$i].Trim()) { 'date-time' { $idxDateTime = $i } 'server-hostname' { $idxServer = $i } 'event-id' { $idxEventId = $i } 'recipient-address' { $idxRecipient = $i } 'total-bytes' { $idxBytes = $i } 'message-subject' { $idxSubject = $i } 'sender-address' { $idxSender = $i } } } } continue } $parts = $line.Split(',') if ($parts.Length -le $idxEventId) { continue } $off = [Math]::Max(0, $parts.Length - $totalF) $dateStr = if ($parts[$idxDateTime].Length -ge 10) { $parts[$idxDateTime].Substring(0, 10) } else { '' } if ($useDateFrom -and [string]::CompareOrdinal($dateStr, $DateFrom) -lt 0) { continue } if ($useDateTo -and [string]::CompareOrdinal($dateStr, $DateTo) -gt 0) { continue } $eventId = $parts[$idxEventId].Trim() $isRecv = $eventId -eq 'RECEIVE' $isSend = $eventId -eq 'SEND' $isDel = $eventId -eq 'DELIVER' $bytes = 0L if ($parts.Length -gt $idxBytes) { [void][long]::TryParse($parts[$idxBytes].Trim(), [ref]$bytes) } switch ($Mode) { 'Server' { if (-not ($isRecv -or $isSend)) { continue } $si = if ($idxServer -ge $idxSubject) { $idxServer + $off } else { $idxServer } if ($parts.Length -le $si) { continue } $key = $parts[$si].Trim() if ($key.Length -eq 0) { continue } $arr = $null if (-not $result.TryGetValue($key, [ref]$arr)) { $arr = [long[]]@(0,0,0,0); $result[$key] = $arr } if ($isSend) { $arr[0]++; $arr[2] += $bytes } else { $arr[1]++; $arr[3] += $bytes } } 'Day' { if (-not ($isRecv -or $isSend)) { continue } $key = $dateStr if ($key.Length -eq 0) { continue } $arr = $null if (-not $result.TryGetValue($key, [ref]$arr)) { $arr = [long[]]@(0,0,0,0); $result[$key] = $arr } if ($isSend) { $arr[0]++; $arr[2] += $bytes } else { $arr[1]++; $arr[3] += $bytes } } 'Recipient' { if (-not ($isRecv -or $isDel)) { continue } $ri = if ($idxRecipient -ge $idxSubject) { $idxRecipient + $off } else { $idxRecipient } if ($parts.Length -le $ri) { continue } $raw = $parts[$ri] if ($raw.Length -gt 1 -and $raw[0] -eq '"') { $raw = $raw.Substring(1, $raw.Length - 2) } foreach ($addr in $raw.Split(';')) { $addr = $addr.Trim() if ($addr.Length -eq 0) { continue } $arr = $null if (-not $result.TryGetValue($addr, [ref]$arr)) { $arr = [long[]]@(0); $result[$addr] = $arr } $arr[0]++ } } 'Sender' { if (-not $isRecv) { continue } $si = if ($idxSender -ge $idxSubject) { $idxSender + $off } else { $idxSender } if ($parts.Length -le $si) { continue } $addr = $parts[$si].Trim() if ($addr.Length -gt 1 -and $addr[0] -eq '"') { $addr = $addr.Substring(1, $addr.Length - 2) } if ($addr.Length -eq 0) { continue } $arr = $null if (-not $result.TryGetValue($addr, [ref]$arr)) { $arr = [long[]]@(0); $result[$addr] = $arr } $arr[0]++ } } } } finally { $sr.Dispose() } } catch { } return $result } # ══════════════════════════════════════════════════════════════════════════════ # SMTP-QUERY-ENGINE # ══════════════════════════════════════════════════════════════════════════════ function Invoke-SmtpReceiveHits { param( [string[]]$Files, $UI, [string]$ConnectorFilter, [string]$DateFrom, [string]$DateTo, [bool]$ResolveDns ) $total = $Files.Count $cpuN = [Environment]::ProcessorCount $parseEndPct = if ($ResolveDns) { 83 } else { 99 } $pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $cpuN) $pool.Open() $jobs = [System.Collections.Generic.List[object]]::new() foreach ($f in $Files) { $ps = [System.Management.Automation.PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($script:ParseBlock).AddArgument($f).AddArgument($ConnectorFilter).AddArgument($DateFrom).AddArgument($DateTo) $jobs.Add([PSCustomObject]@{ PS = $ps; H = $ps.BeginInvoke() }) } $merged = [System.Collections.Generic.Dictionary[string,long]]::new(1024) $pending = [System.Collections.Generic.List[object]]($jobs) while ($pending.Count -gt 0) { $done = @($pending | Where-Object { $_.H.IsCompleted }) foreach ($j in $done) { try { $dict = ($j.PS.EndInvoke($j.H))[0] if ($dict -and $dict.Count -gt 0) { foreach ($kv in $dict.GetEnumerator()) { $cur = 0L [void]$merged.TryGetValue($kv.Key, [ref]$cur) $merged[$kv.Key] = $cur + $kv.Value } } } catch { } $j.PS.Dispose() [void]$pending.Remove($j) } $doneN = $total - $pending.Count Set-Progress $UI ([int]($doneN / $total * $parseEndPct)) "$doneN / $total files processed..." if ($pending.Count -gt 0) { Start-Sleep -Milliseconds 120 } } $pool.Close(); $pool.Dispose() if ($merged.Count -eq 0) { return @() } $ips = @($merged.Keys) $dnsMap = @{} if ($ResolveDns) { Set-Progress $UI ($parseEndPct + 1) "Reverse DNS for $($ips.Count) IPs..." $dnsPool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool( 1, [Math]::Min($cpuN * 8, 64)) $dnsPool.Open() $dnsJobs = [System.Collections.Generic.List[object]]::new() foreach ($ip in $ips) { $ps = [System.Management.Automation.PowerShell]::Create() $ps.RunspacePool = $dnsPool [void]$ps.AddScript($script:DnsBlock).AddArgument($ip) $dnsJobs.Add([PSCustomObject]@{ PS = $ps; H = $ps.BeginInvoke() }) } $dnsWait = [System.Collections.Generic.List[object]]($dnsJobs) $dnsRange = 99 - $parseEndPct - 1 while ($dnsWait.Count -gt 0) { $done = @($dnsWait | Where-Object { $_.H.IsCompleted }) foreach ($j in $done) { try { $r = ($j.PS.EndInvoke($j.H))[0] if ($r) { $dnsMap[$r.IP] = $r.Name } } catch { } $j.PS.Dispose() [void]$dnsWait.Remove($j) } $doneN = $ips.Count - $dnsWait.Count Set-Progress $UI ($parseEndPct + 1 + [int]($doneN / $ips.Count * $dnsRange)) "rDNS: $doneN / $($ips.Count) resolved..." if ($dnsWait.Count -gt 0) { Start-Sleep -Milliseconds 80 } } $dnsPool.Close(); $dnsPool.Dispose() } $out = foreach ($ip in $ips) { [PSCustomObject]@{ IP = $ip Name = if ($dnsMap.ContainsKey($ip)) { $dnsMap[$ip] } else { '-' } Hits = $merged[$ip] } } return $out | Sort-Object { [long]$_.Hits } -Descending } # ══════════════════════════════════════════════════════════════════════════════ # MSGTRACK-QUERY-ENGINE # ══════════════════════════════════════════════════════════════════════════════ function Invoke-MessageTrackingQuery { param([string[]]$Files, $UI, [string]$Mode, [string]$DateFrom, [string]$DateTo) $total = $Files.Count $cpuN = [Environment]::ProcessorCount $pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, $cpuN) $pool.Open() $jobs = [System.Collections.Generic.List[object]]::new() foreach ($f in $Files) { $ps = [System.Management.Automation.PowerShell]::Create() $ps.RunspacePool = $pool [void]$ps.AddScript($script:MsgTrackBlock).AddArgument($f).AddArgument($Mode).AddArgument($DateFrom).AddArgument($DateTo) $jobs.Add([PSCustomObject]@{ PS = $ps; H = $ps.BeginInvoke() }) } $merged = [System.Collections.Generic.Dictionary[string,long[]]]::new(256) $pending = [System.Collections.Generic.List[object]]($jobs) while ($pending.Count -gt 0) { $done = @($pending | Where-Object { $_.H.IsCompleted }) foreach ($j in $done) { try { $dict = ($j.PS.EndInvoke($j.H))[0] if ($dict -and $dict.Count -gt 0) { foreach ($kv in $dict.GetEnumerator()) { $existing = $null if ($merged.TryGetValue($kv.Key, [ref]$existing)) { for ($i = 0; $i -lt $kv.Value.Length; $i++) { $existing[$i] += $kv.Value[$i] } } else { $merged[$kv.Key] = [long[]]$kv.Value.Clone() } } } } catch { } $j.PS.Dispose() [void]$pending.Remove($j) } $doneN = $total - $pending.Count Set-Progress $UI ([int]($doneN / $total * 99)) "$doneN / $total files processed..." if ($pending.Count -gt 0) { Start-Sleep -Milliseconds 120 } } $pool.Close(); $pool.Dispose() if ($merged.Count -eq 0) { return @() } switch ($Mode) { 'Server' { $out = foreach ($kv in $merged.GetEnumerator()) { [PSCustomObject]@{ Servername = $kv.Key Overall = $kv.Value[0] + $kv.Value[1] VolumeMB = [Math]::Round(($kv.Value[2] + $kv.Value[3]) / 1MB, 2) SendCount = $kv.Value[0] RecvCount = $kv.Value[1] SendVolMB = [Math]::Round($kv.Value[2] / 1MB, 2) RecvVolMB = [Math]::Round($kv.Value[3] / 1MB, 2) } } return @($out | Sort-Object { [long]$_.Overall } -Descending) } 'Day' { $out = foreach ($kv in $merged.GetEnumerator()) { [PSCustomObject]@{ Date = $kv.Key # stays ISO for sorting; display formatting happens in grid population DateSort = $kv.Key SendCount = $kv.Value[0] RecvCount = $kv.Value[1] SendVolMB = [Math]::Round($kv.Value[2] / 1MB, 2) RecvVolMB = [Math]::Round($kv.Value[3] / 1MB, 2) } } return @($out | Sort-Object DateSort -Descending) } 'Recipient' { $sorted = @($merged.GetEnumerator() | Sort-Object { $_.Value[0] } -Descending | Select-Object -First 20) return @(foreach ($kv in $sorted) { [PSCustomObject]@{ Recipient = $kv.Key; Count = $kv.Value[0] } }) } 'Sender' { $sorted = @($merged.GetEnumerator() | Sort-Object { $_.Value[0] } -Descending | Select-Object -First 20) return @(foreach ($kv in $sorted) { [PSCustomObject]@{ Sender = $kv.Key; Count = $kv.Value[0] } }) } } } function Set-Progress { param($UI, [int]$Pct, [string]$Msg) $UI.Progress.Value = [Math]::Max(0, [Math]::Min(100, $Pct)) $UI.Status.Text = $Msg [System.Windows.Forms.Application]::DoEvents() } # ══════════════════════════════════════════════════════════════════════════════ # GRID COLUMNS — rebuilt dynamically per query type # ══════════════════════════════════════════════════════════════════════════════ function Set-GridColumns { param($Dgv, [string]$Mode) [void]$Dgv.Columns.Clear() $fntB = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold) $cols = switch ($Mode) { 'SmtpReceive' { @( @{ N='IP'; H='IP Address'; W=20; R=$false } @{ N='Name'; H='Hostname (rDNS)'; W=58; R=$false } @{ N='Hits'; H='Hits'; W=14; R=$true } )} 'Server' { @( @{ N='Servername'; H='Server Name'; W=20; R=$false } @{ N='Overall'; H='Total'; W=11; R=$true } @{ N='VolumeMB'; H='Volume (MB)'; W=14; R=$true } @{ N='SendCount'; H='Sent'; W=11; R=$true } @{ N='RecvCount'; H='Received'; W=11; R=$true } @{ N='SendVolMB'; H='Vol. Send (MB)'; W=14; R=$true } @{ N='RecvVolMB'; H='Vol. Recv (MB)'; W=14; R=$true } )} 'Day' { @( @{ N='Date'; H='Date'; W=16; R=$false } @{ N='SendCount'; H='Sent'; W=18; R=$true } @{ N='RecvCount'; H='Received'; W=18; R=$true } @{ N='SendVolMB'; H='Vol. Send (MB)'; W=24; R=$true } @{ N='RecvVolMB'; H='Vol. Recv (MB)'; W=24; R=$true } )} 'Recipient' { @( @{ N='Recipient'; H='Recipient'; W=78; R=$false } @{ N='Count'; H='Count'; W=22; R=$true } )} 'Sender' { @( @{ N='Sender'; H='Sender'; W=78; R=$false } @{ N='Count'; H='Count'; W=22; R=$true } )} } foreach ($c in $cols) { $col = New-Object System.Windows.Forms.DataGridViewTextBoxColumn $col.Name = $c.N $col.HeaderText = $c.H $col.FillWeight = $c.W if ($c.R) { $col.DefaultCellStyle.Alignment = 'MiddleRight' $col.HeaderCell.Style.Alignment = 'MiddleRight' } [void]$Dgv.Columns.Add($col) } } # ══════════════════════════════════════════════════════════════════════════════ # AUTO-DISCOVERY # ══════════════════════════════════════════════════════════════════════════════ function Find-ExchangeSmtpReceiveDirs { $logSubs = @( 'TransportRoles\Logs\FrontEnd\ProtocolLog\SmtpReceive', 'TransportRoles\Logs\Hub\ProtocolLog\SmtpReceive', 'TransportRoles\Logs\Edge\ProtocolLog\SmtpReceive' ) $found = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($ver in @('v15', 'v14')) { $key = "HKLM:\SOFTWARE\Microsoft\ExchangeServer\$ver\Setup" if (Test-Path $key) { $installPath = (Get-ItemProperty $key -ErrorAction SilentlyContinue).MsiInstallPath if ($installPath) { $base = $installPath.TrimEnd('\') foreach ($sub in $logSubs) { $dir = Join-Path $base $sub if (Test-Path $dir) { [void]$found.Add($dir) } } } } } $bases = @( 'Program Files\Microsoft\Exchange Server\V15', 'Program Files\Microsoft\Exchange Server\V14', 'Program Files (x86)\Microsoft\Exchange Server\V15', 'Program Files (x86)\Microsoft\Exchange Server\V14', 'Exchange Server\V15', 'Exchange Server\V14', 'Exchange\V15', 'Exchange\V14' ) foreach ($drive in ([System.IO.DriveInfo]::GetDrives() | Where-Object { $_.DriveType -eq 'Fixed' -and $_.IsReady })) { foreach ($base in $bases) { foreach ($sub in $logSubs) { $dir = Join-Path (Join-Path $drive.RootDirectory.FullName $base) $sub if (Test-Path $dir) { [void]$found.Add($dir) } } } } return [string[]]$found } function Find-ExchangeMessageTrackingDirs { $logSub = 'TransportRoles\Logs\MessageTracking' $found = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($ver in @('v15', 'v14')) { $key = "HKLM:\SOFTWARE\Microsoft\ExchangeServer\$ver\Setup" if (Test-Path $key) { $installPath = (Get-ItemProperty $key -ErrorAction SilentlyContinue).MsiInstallPath if ($installPath) { $dir = Join-Path $installPath.TrimEnd('\') $logSub if (Test-Path $dir) { [void]$found.Add($dir) } } } } $bases = @( 'Program Files\Microsoft\Exchange Server\V15', 'Program Files\Microsoft\Exchange Server\V14', 'Program Files (x86)\Microsoft\Exchange Server\V15', 'Program Files (x86)\Microsoft\Exchange Server\V14', 'Exchange Server\V15', 'Exchange Server\V14', 'Exchange\V15', 'Exchange\V14' ) foreach ($drive in ([System.IO.DriveInfo]::GetDrives() | Where-Object { $_.DriveType -eq 'Fixed' -and $_.IsReady })) { foreach ($base in $bases) { $dir = Join-Path (Join-Path $drive.RootDirectory.FullName $base) $logSub if (Test-Path $dir) { [void]$found.Add($dir) } } } return [string[]]$found } function Set-DefaultDirs { param($UI, [string]$Mode) $UI.DirList.Items.Clear() $dirs = if ($Mode -eq 'SmtpReceive') { Find-ExchangeSmtpReceiveDirs } else { Find-ExchangeMessageTrackingDirs } foreach ($d in $dirs) { [void]$UI.DirList.Items.Add($d) } } # ══════════════════════════════════════════════════════════════════════════════ # UI # ══════════════════════════════════════════════════════════════════════════════ function New-MainForm { $fnt = New-Object System.Drawing.Font('Segoe UI', 9) $fntB = New-Object System.Drawing.Font('Segoe UI', 9, [System.Drawing.FontStyle]::Bold) $fntSm = New-Object System.Drawing.Font('Segoe UI', 8) $blue = [System.Drawing.Color]::FromArgb(0, 120, 212) $hdrBg = [System.Drawing.Color]::FromArgb(228, 234, 246) $altBg = [System.Drawing.Color]::FromArgb(245, 247, 252) $sepClr = [System.Drawing.Color]::FromArgb(200, 205, 215) $grayClr = [System.Drawing.Color]::FromArgb(130, 130, 130) # ── Main window ─────────────────────────────────────────────────────────── $form = New-Object System.Windows.Forms.Form $form.Text = 'Exchange Log Analyzer' $form.Size = New-Object System.Drawing.Size(980, 780) $form.MinimumSize = New-Object System.Drawing.Size(760, 580) $form.StartPosition = 'CenterScreen' $form.Font = $fnt # ── Panel: Log directories ──────────────────────────────────────────────── $pDir = New-Object System.Windows.Forms.Panel $pDir.Dock = 'Top' $pDir.Height = 158 $lblDir = New-Object System.Windows.Forms.Label $lblDir.Text = 'Log Directories:' $lblDir.Location = New-Object System.Drawing.Point(12, 8) $lblDir.AutoSize = $true $lblDir.Font = $fntB $lstDir = New-Object System.Windows.Forms.ListBox $lstDir.Location = New-Object System.Drawing.Point(12, 28) $lstDir.Size = New-Object System.Drawing.Size(916, 88) $lstDir.Anchor = 'Top,Left,Right' $lstDir.SelectionMode = 'MultiExtended' $btnAdd = New-Object System.Windows.Forms.Button $btnAdd.Text = '+ Add Directory' $btnAdd.Location = New-Object System.Drawing.Point(12, 122) $btnAdd.Size = New-Object System.Drawing.Size(115, 28) $btnRem = New-Object System.Windows.Forms.Button $btnRem.Text = '− Remove' $btnRem.Location = New-Object System.Drawing.Point(134, 122) $btnRem.Size = New-Object System.Drawing.Size(88, 28) $pDir.Controls.AddRange(@($lblDir, $lstDir, $btnAdd, $btnRem)) # ── Panel: Query + filters ──────────────────────────────────────────────── $pFilter = New-Object System.Windows.Forms.Panel $pFilter.Dock = 'Top' $pFilter.Height = 130 $sepTop = New-Object System.Windows.Forms.Label $sepTop.Dock = 'Top' $sepTop.Height = 1 $sepTop.BackColor = $sepClr # Row 1 — query type (y=7) $lblQ = New-Object System.Windows.Forms.Label $lblQ.Text = 'Query:' $lblQ.Location = New-Object System.Drawing.Point(12, 9) $lblQ.AutoSize = $true $lblQ.Font = $fntB $rbSmtp = New-Object System.Windows.Forms.RadioButton $rbSmtp.Text = 'SMTP-Receive Hits' $rbSmtp.Location = New-Object System.Drawing.Point(68, 7) $rbSmtp.AutoSize = $true $rbSmtp.Checked = $true $rbServer = New-Object System.Windows.Forms.RadioButton $rbServer.Text = 'Server Statistics' $rbServer.Location = New-Object System.Drawing.Point(234, 7) $rbServer.AutoSize = $true $rbPerDay = New-Object System.Windows.Forms.RadioButton $rbPerDay.Text = 'Mails per Day' $rbPerDay.Location = New-Object System.Drawing.Point(358, 7) $rbPerDay.AutoSize = $true $rbTopRecip = New-Object System.Windows.Forms.RadioButton $rbTopRecip.Text = 'Top 20 Recipients' $rbTopRecip.Location = New-Object System.Drawing.Point(460, 7) $rbTopRecip.AutoSize = $true $rbTopSender = New-Object System.Windows.Forms.RadioButton $rbTopSender.Text = 'Top 20 Senders' $rbTopSender.Location = New-Object System.Drawing.Point(598, 7) $rbTopSender.AutoSize = $true # Row 2 — options (y=32) $chkSub = New-Object System.Windows.Forms.CheckBox $chkSub.Text = 'Include Subdirectories' $chkSub.Location = New-Object System.Drawing.Point(68, 32) $chkSub.AutoSize = $true $chkSub.Checked = $true $chkDns = New-Object System.Windows.Forms.CheckBox $chkDns.Text = 'Reverse DNS' $chkDns.Location = New-Object System.Drawing.Point(216, 32) $chkDns.AutoSize = $true $chkDns.Checked = $true # Separator (y=57) $sepInner = New-Object System.Windows.Forms.Label $sepInner.Location = New-Object System.Drawing.Point(12, 57) $sepInner.Size = New-Object System.Drawing.Size(916, 1) $sepInner.Anchor = 'Top,Left,Right' $sepInner.BackColor = $sepClr # Row 3 — connector filter (y=65) $chkConn = New-Object System.Windows.Forms.CheckBox $chkConn.Text = 'Connector Filter:' $chkConn.Location = New-Object System.Drawing.Point(12, 65) $chkConn.AutoSize = $true $txtConn = New-Object System.Windows.Forms.TextBox $txtConn.Text = 'Relay' $txtConn.Location = New-Object System.Drawing.Point(132, 64) $txtConn.Size = New-Object System.Drawing.Size(170, 23) $txtConn.Enabled = $false $lblConnHint = New-Object System.Windows.Forms.Label $lblConnHint.Text = '(substring, case-insensitive)' $lblConnHint.Location = New-Object System.Drawing.Point(310, 68) $lblConnHint.AutoSize = $true $lblConnHint.ForeColor = $grayClr $lblConnHint.Font = $fntSm # Row 4 — date filter (y=96) $chkDate = New-Object System.Windows.Forms.CheckBox $chkDate.Text = 'Date Filter:' $chkDate.Location = New-Object System.Drawing.Point(12, 98) $chkDate.AutoSize = $true $lblVon = New-Object System.Windows.Forms.Label $lblVon.Text = 'From' $lblVon.Location = New-Object System.Drawing.Point(104, 101) $lblVon.AutoSize = $true $lblVon.ForeColor = $grayClr $dtpFrom = New-Object System.Windows.Forms.DateTimePicker $dtpFrom.Location = New-Object System.Drawing.Point(144, 97) $dtpFrom.Size = New-Object System.Drawing.Size(125, 23) $dtpFrom.Format = 'Short' $dtpFrom.Value = (Get-Date).AddDays(-30) $dtpFrom.Enabled = $false $lblBis = New-Object System.Windows.Forms.Label $lblBis.Text = 'To' $lblBis.Location = New-Object System.Drawing.Point(277, 101) $lblBis.AutoSize = $true $lblBis.ForeColor = $grayClr $dtpTo = New-Object System.Windows.Forms.DateTimePicker $dtpTo.Location = New-Object System.Drawing.Point(296, 97) $dtpTo.Size = New-Object System.Drawing.Size(125, 23) $dtpTo.Format = 'Short' $dtpTo.Value = Get-Date $dtpTo.Enabled = $false $pFilter.Controls.AddRange(@( $sepTop, $lblQ, $rbSmtp, $rbServer, $rbPerDay, $rbTopRecip, $rbTopSender, $chkSub, $chkDns, $sepInner, $chkConn, $txtConn, $lblConnHint, $chkDate, $lblVon, $dtpFrom, $lblBis, $dtpTo )) # ── Panel: Actions + progress ───────────────────────────────────────────── $pAct = New-Object System.Windows.Forms.Panel $pAct.Dock = 'Top' $pAct.Height = 62 $sepAct = New-Object System.Windows.Forms.Label $sepAct.Dock = 'Top' $sepAct.Height = 1 $sepAct.BackColor = $sepClr $btnStart = New-Object System.Windows.Forms.Button $btnStart.Text = '▶ Start' $btnStart.Location = New-Object System.Drawing.Point(12, 12) $btnStart.Size = New-Object System.Drawing.Size(90, 32) $btnStart.BackColor = $blue $btnStart.ForeColor = [System.Drawing.Color]::White $btnStart.FlatStyle = 'Flat' $btnStart.Font = $fntB $btnStart.FlatAppearance.BorderSize = 0 $btnExp = New-Object System.Windows.Forms.Button $btnExp.Text = 'CSV Export' $btnExp.Location = New-Object System.Drawing.Point(108, 12) $btnExp.Size = New-Object System.Drawing.Size(88, 32) $btnExp.Enabled = $false $lblSt = New-Object System.Windows.Forms.Label $lblSt.Text = 'Ready.' $lblSt.Location = New-Object System.Drawing.Point(206, 20) $lblSt.AutoSize = $true $pb = New-Object System.Windows.Forms.ProgressBar $pb.Location = New-Object System.Drawing.Point(12, 47) $pb.Size = New-Object System.Drawing.Size(916, 11) $pb.Anchor = 'Top,Left,Right' $pb.Style = 'Continuous' $pAct.Controls.AddRange(@($sepAct, $btnStart, $btnExp, $lblSt, $pb)) # ── Panel: Results grid ─────────────────────────────────────────────────── $pGrid = New-Object System.Windows.Forms.Panel $pGrid.Dock = 'Fill' $pGrid.Padding = New-Object System.Windows.Forms.Padding(12, 4, 12, 12) $sepGrid = New-Object System.Windows.Forms.Label $sepGrid.Dock = 'Top' $sepGrid.Height = 1 $sepGrid.BackColor = $sepClr $lblR = New-Object System.Windows.Forms.Label $lblR.Text = 'Results:' $lblR.Dock = 'Top' $lblR.Height = 22 $lblR.Font = $fntB $lblR.Padding = New-Object System.Windows.Forms.Padding(0, 4, 0, 0) $dgv = New-Object System.Windows.Forms.DataGridView $dgv.Dock = 'Fill' $dgv.AllowUserToAddRows = $false $dgv.AllowUserToDeleteRows = $false $dgv.ReadOnly = $true $dgv.SelectionMode = 'FullRowSelect' $dgv.RowHeadersVisible = $false $dgv.AutoSizeColumnsMode = 'Fill' $dgv.ClipboardCopyMode = 'EnableWithoutHeaderText' $dgv.BorderStyle = 'FixedSingle' $dgv.GridColor = [System.Drawing.Color]::FromArgb(210, 215, 225) $dgv.EnableHeadersVisualStyles = $false $dgv.ColumnHeadersDefaultCellStyle.BackColor = $hdrBg $dgv.ColumnHeadersDefaultCellStyle.ForeColor = [System.Drawing.Color]::FromArgb(30, 30, 30) $dgv.ColumnHeadersDefaultCellStyle.Font = $fntB $dgv.RowTemplate.Height = 22 $dgv.DefaultCellStyle.ForeColor = [System.Drawing.Color]::Black $dgv.DefaultCellStyle.BackColor = [System.Drawing.Color]::White $dgv.DefaultCellStyle.SelectionForeColor = [System.Drawing.Color]::White $dgv.DefaultCellStyle.SelectionBackColor = $blue $dgv.AlternatingRowsDefaultCellStyle.BackColor = $altBg $dgv.AlternatingRowsDefaultCellStyle.ForeColor = [System.Drawing.Color]::Black $pGrid.Controls.AddRange(@($dgv, $lblR, $sepGrid)) $form.Controls.AddRange(@($pGrid, $pAct, $pFilter, $pDir)) return [PSCustomObject]@{ Form = $form DirList = $lstDir; BtnAdd = $btnAdd; BtnRemove = $btnRem RbSmtp = $rbSmtp; RbServer = $rbServer; RbPerDay = $rbPerDay RbTopRecip = $rbTopRecip; RbTopSender = $rbTopSender ChkSub = $chkSub; ChkDns = $chkDns ChkConn = $chkConn; TxtConn = $txtConn ChkDate = $chkDate; DtpFrom = $dtpFrom; DtpTo = $dtpTo BtnStart = $btnStart; BtnExport = $btnExp Status = $lblSt; Progress = $pb; Grid = $dgv } } # ══════════════════════════════════════════════════════════════════════════════ # PATH INPUT DIALOG — supports local paths and UNC paths # ══════════════════════════════════════════════════════════════════════════════ function Show-PathInputDialog { param([System.Windows.Forms.Form]$Owner) $fnt = New-Object System.Drawing.Font('Segoe UI', 9) $dlg = New-Object System.Windows.Forms.Form $dlg.Text = 'Add Log Directory' $dlg.Size = New-Object System.Drawing.Size(580, 115) $dlg.StartPosition = 'CenterParent' $dlg.FormBorderStyle = 'FixedDialog' $dlg.MaximizeBox = $false $dlg.MinimizeBox = $false $dlg.Font = $fnt $txt = New-Object System.Windows.Forms.TextBox $txt.Location = New-Object System.Drawing.Point(12, 12) $txt.Size = New-Object System.Drawing.Size(432, 23) $txt.Anchor = 'Top,Left,Right' # Pre-fill with clipboard if it looks like a path $clip = [System.Windows.Forms.Clipboard]::GetText().Trim() if ($clip -match '^([A-Za-z]:\\|\\\\)') { $txt.Text = $clip } $btnBrowse = New-Object System.Windows.Forms.Button $btnBrowse.Text = 'Browse...' $btnBrowse.Location = New-Object System.Drawing.Point(452, 11) $btnBrowse.Size = New-Object System.Drawing.Size(100, 25) $btnBrowse.Anchor = 'Top,Right' $btnOk = New-Object System.Windows.Forms.Button $btnOk.Text = 'OK' $btnOk.DialogResult = 'OK' $btnOk.Location = New-Object System.Drawing.Point(390, 46) $btnOk.Size = New-Object System.Drawing.Size(78, 27) $btnCancel = New-Object System.Windows.Forms.Button $btnCancel.Text = 'Cancel' $btnCancel.DialogResult = 'Cancel' $btnCancel.Location = New-Object System.Drawing.Point(474, 46) $btnCancel.Size = New-Object System.Drawing.Size(78, 27) $dlg.AcceptButton = $btnOk $dlg.CancelButton = $btnCancel $btnBrowse.Add_Click({ $fbd = New-Object System.Windows.Forms.FolderBrowserDialog $fbd.Description = 'Select log directory' $fbd.ShowNewFolderButton = $false if ($txt.Text.Trim().Length -gt 0) { try { $fbd.SelectedPath = $txt.Text.Trim() } catch { } } if ($fbd.ShowDialog($dlg) -eq 'OK') { $txt.Text = $fbd.SelectedPath } }) $dlg.Controls.AddRange(@($txt, $btnBrowse, $btnOk, $btnCancel)) if ($dlg.ShowDialog($Owner) -eq 'OK') { $p = $txt.Text.Trim() if ($p.Length -gt 0) { return $p } } return $null } # ══════════════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════════════ $ui = New-MainForm $form = $ui.Form Set-GridColumns $ui.Grid 'SmtpReceive' Set-DefaultDirs $ui 'SmtpReceive' # Query-type switch: auto-update directories, columns, and available options $onQueryChange = { $mode = if ($ui.RbSmtp.Checked) { 'SmtpReceive' } elseif ($ui.RbServer.Checked) { 'Server' } elseif ($ui.RbPerDay.Checked) { 'Day' } elseif ($ui.RbTopRecip.Checked) { 'Recipient' } else { 'Sender' } $isSmtp = $ui.RbSmtp.Checked $ui.ChkDns.Enabled = $isSmtp $ui.ChkConn.Enabled = $isSmtp $ui.TxtConn.Enabled = $isSmtp -and $ui.ChkConn.Checked Set-DefaultDirs $ui $mode Set-GridColumns $ui.Grid $mode $ui.Grid.Rows.Clear() $ui.BtnExport.Enabled = $false Set-Progress $ui 0 'Ready.' } $ui.RbSmtp.Add_CheckedChanged($onQueryChange) $ui.RbServer.Add_CheckedChanged($onQueryChange) $ui.RbPerDay.Add_CheckedChanged($onQueryChange) $ui.RbTopRecip.Add_CheckedChanged($onQueryChange) $ui.RbTopSender.Add_CheckedChanged($onQueryChange) $ui.BtnAdd.Add_Click({ $path = Show-PathInputDialog $form if ($path -and $ui.DirList.Items -notcontains $path) { [void]$ui.DirList.Items.Add($path) } }) $ui.BtnRemove.Add_Click({ @($ui.DirList.SelectedItems) | ForEach-Object { $ui.DirList.Items.Remove($_) } }) # Ctrl+V: paste one or more paths from clipboard; Delete: remove selected $ui.DirList.Add_KeyDown({ param($s, $e) if ($e.Control -and $e.KeyCode -eq 'V') { $clip = [System.Windows.Forms.Clipboard]::GetText() foreach ($line in ($clip -split '\r?\n')) { $p = $line.Trim() if ($p.Length -gt 0 -and $s.Items -notcontains $p) { [void]$s.Items.Add($p) } } $e.Handled = $true $e.SuppressKeyPress = $true } elseif ($e.KeyCode -eq 'Delete') { @($s.SelectedItems) | ForEach-Object { $s.Items.Remove($_) } $e.Handled = $true } }) $ui.ChkConn.Add_CheckedChanged({ $ui.TxtConn.Enabled = $ui.ChkConn.Checked }) $ui.ChkDate.Add_CheckedChanged({ $en = $ui.ChkDate.Checked $ui.DtpFrom.Enabled = $en $ui.DtpTo.Enabled = $en }) $ui.BtnStart.Add_Click({ if ($ui.DirList.Items.Count -eq 0) { [System.Windows.Forms.MessageBox]::Show( 'Please add at least one directory.', 'No Directory', 'OK', 'Warning') | Out-Null return } $mode = if ($ui.RbSmtp.Checked) { 'SmtpReceive' } elseif ($ui.RbServer.Checked) { 'Server' } elseif ($ui.RbPerDay.Checked) { 'Day' } elseif ($ui.RbTopRecip.Checked) { 'Recipient' } else { 'Sender' } $ui.BtnStart.Enabled = $false $ui.BtnExport.Enabled = $false $ui.Grid.Rows.Clear() Set-GridColumns $ui.Grid $mode Set-Progress $ui 0 'Collecting log files...' $recurse = $ui.ChkSub.Checked $files = @( $ui.DirList.Items | ForEach-Object { $p = @{ Path = $_; Filter = '*.log'; ErrorAction = 'SilentlyContinue' } if ($recurse) { $p.Recurse = $true } Get-ChildItem @p | Select-Object -ExpandProperty FullName } ) if ($files.Count -eq 0) { Set-Progress $ui 0 'No .log files found.' $ui.BtnStart.Enabled = $true return } $dateFrom = if ($ui.ChkDate.Checked) { $ui.DtpFrom.Value.ToString('yyyy-MM-dd') } else { '' } $dateTo = if ($ui.ChkDate.Checked) { $ui.DtpTo.Value.ToString('yyyy-MM-dd') } else { '' } # Pre-filter by date embedded in filename (yyyyMMdd pattern, e.g. MSGTRK20260514-001.LOG). # Files outside the selected range are skipped entirely — no I/O, no parsing. if ($dateFrom.Length -gt 0 -or $dateTo.Length -gt 0) { $files = @($files | Where-Object { $stem = [System.IO.Path]::GetFileNameWithoutExtension($_) if ($stem -match '(\d{8})') { $fd = $matches[1] $fdIso = "$($fd.Substring(0,4))-$($fd.Substring(4,2))-$($fd.Substring(6,2))" if ($dateFrom.Length -gt 0 -and [string]::CompareOrdinal($fdIso, $dateFrom) -lt 0) { return $false } if ($dateTo.Length -gt 0 -and [string]::CompareOrdinal($fdIso, $dateTo) -gt 0) { return $false } } return $true }) if ($files.Count -eq 0) { Set-Progress $ui 0 'No files match the selected date range.' $ui.BtnStart.Enabled = $true return } } Set-Progress $ui 1 "$($files.Count) files — starting analysis..." if ($mode -eq 'SmtpReceive') { $connFilter = if ($ui.ChkConn.Checked) { $ui.TxtConn.Text.Trim() } else { '' } $result = @(Invoke-SmtpReceiveHits -Files $files -UI $ui ` -ConnectorFilter $connFilter -DateFrom $dateFrom -DateTo $dateTo -ResolveDns $ui.ChkDns.Checked) } else { $result = @(Invoke-MessageTrackingQuery -Files $files -UI $ui -Mode $mode -DateFrom $dateFrom -DateTo $dateTo) } $colNames = @($ui.Grid.Columns | ForEach-Object { $_.Name }) try { foreach ($r in $result) { $idx = $ui.Grid.Rows.Add() foreach ($col in $colNames) { $ui.Grid.Rows[$idx].Cells[$col].Value = [string]($r.$col) } } } catch { Set-Progress $ui 100 "Display error: $($_.Exception.Message)" $ui.BtnStart.Enabled = $true return } $ui.Grid.Refresh() $note = if ($mode -eq 'SmtpReceive' -and -not $ui.ChkDns.Checked) { ' (no rDNS)' } else { '' } Set-Progress $ui 100 "Done — $($result.Count) entries from $($files.Count) files$note" $ui.BtnStart.Enabled = $true $ui.BtnExport.Enabled = ($ui.Grid.Rows.Count -gt 0) }) $ui.BtnExport.Add_Click({ $sfd = New-Object System.Windows.Forms.SaveFileDialog $sfd.Filter = 'CSV files (*.csv)|*.csv|All files (*.*)|*.*' $sfd.FileName = "exchange-log-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv" if ($sfd.ShowDialog($form) -ne 'OK') { return } $colNames = @($ui.Grid.Columns | ForEach-Object { $_.Name }) $lines = [System.Collections.Generic.List[string]]::new() $lines.Add(($colNames -join ',')) foreach ($row in $ui.Grid.Rows) { $vals = foreach ($col in $colNames) { '"' + ([string]$row.Cells[$col].Value).Replace('"','""') + '"' } $lines.Add($vals -join ',') } [System.IO.File]::WriteAllLines($sfd.FileName, $lines, [System.Text.Encoding]::UTF8) [System.Windows.Forms.MessageBox]::Show( "Exported to:`n$($sfd.FileName)", 'Export Successful', 'OK', 'Information') | Out-Null }) [void]$form.ShowDialog()