# EULE Outlook helper - opens a reply in Outlook Classic from the Postkast page. # # Called by Windows for links like: # eule-outlook:reply?m=&s=&subj=&from=&all=1&h= # eule-outlook:forward?m=...&s=...&to=&h=... (delegate: forward with a note) # eule-outlook:open?m=...&s=...&subj=...&from=... (just show the original mail) # eule-outlook:new?to=&cc=&subj=&h=... (a new mail the page prepared) # # It finds the message, opens Reply (or Reply All) in a normal Outlook window with # your signature and the quoted mail, and puts the suggested text at the top. # The text comes from the clipboard (the page copies it there just before the # link) and is only used if its hash matches "h", so random clipboard content # never ends up in a mail. Nothing is ever sent: you review and press Send. # # Kept ASCII-only on purpose: Windows PowerShell 5.1 reads downloaded scripts # without a BOM as ANSI. param([Parameter(Position = 0)][string]$Url) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.Web Add-Type -AssemblyName PresentationFramework function Show-Error([string]$msg) { [void][System.Windows.MessageBox]::Show($msg, 'EULE Outlook helper', 'OK', 'Warning') } function Get-ShortHash([string]$text) { $sha = [System.Security.Cryptography.SHA256]::Create() $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($text)) return (-join ($bytes | ForEach-Object { $_.ToString('x2') })).Substring(0, 16) } function From-Base64Url([string]$v) { $p = $v.Replace('-', '+').Replace('_', '/') switch ($p.Length % 4) { 2 { $p += '==' } 3 { $p += '=' } } return [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) } function Resolve-MessageRef([string]$m, [string]$s) { # Accepts what the Outlook MCP connector returns (omcp2__, # or the older omcp1_) as well as a raw hex EntryID. # Anything unreadable gives empty ids, and the caller falls back to subject search. $entry = ''; $store = '' try { if ($m -match '^omcp2_[0-9A-Fa-f]{16}_([A-Za-z0-9_-]{8,4096})$') { $entry = From-Base64Url $Matches[1] $store = $s } elseif ($m -match '^omcp1_([A-Za-z0-9_-]{8,8192})$') { $obj = (From-Base64Url $Matches[1]) | ConvertFrom-Json $entry = [string]$obj.EntryId $store = [string]$obj.StoreId } else { $entry = $m $store = $s } } catch { $entry = ''; $store = '' } if ($entry -notmatch '^[0-9A-Fa-f]{16,1024}$') { $entry = '' } if ($store -notmatch '^[0-9A-Fa-f]{16,4096}$') { $store = '' } return @{ EntryId = $entry; StoreId = $store } } function Get-Addresses([string]$v) { # "Name , c@d.ee" -> "a@b.ee; c@d.ee". Only plain addresses survive. if (-not $v) { return '' } $found = [regex]::Matches($v, '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}') $list = @() foreach ($f in $found) { if ($list.Count -lt 20 -and $list -notcontains $f.Value) { $list += $f.Value } } return ($list -join '; ') } function Normalize-Subject([string]$s) { if (-not $s) { return '' } $s = $s.Trim() while ($s -match '^(?i)(re|fw|fwd|vs|vl|sv|aw|wg)\s*:\s*') { $s = $s -replace '^(?i)(re|fw|fwd|vs|vl|sv|aw|wg)\s*:\s*', '' } return $s.Trim().ToLowerInvariant() } function Find-ByHeaders($ns, [string]$subject, [string]$from) { # Fallback when the EntryID is missing or no longer valid (e.g. the morning # task ran on another computer): newest matching mail of the last 45 days. $want = Normalize-Subject $subject if (-not $want) { return $null } $since = (Get-Date).AddDays(-45).ToString('g') $skip = @(3, 4, 5, 16, 23) # Deleted, Outbox, Sent, Drafts, Junk $skipIds = @{} foreach ($id in $skip) { try { $skipIds[$ns.GetDefaultFolder($id).EntryID] = $true } catch {} } $best = $null $queue = New-Object System.Collections.Queue foreach ($store in $ns.Stores) { try { $queue.Enqueue($store.GetRootFolder()) } catch {} } while ($queue.Count -gt 0) { $folder = $queue.Dequeue() try { foreach ($sub in $folder.Folders) { $queue.Enqueue($sub) } } catch {} if ($skipIds.ContainsKey($folder.EntryID)) { continue } if ($folder.DefaultItemType -ne 0) { continue } # mail folders only try { $items = $folder.Items.Restrict("[ReceivedTime] >= '$since'") } catch { continue } foreach ($it in $items) { if ($it.Class -ne 43) { continue } # MailItem if ((Normalize-Subject $it.Subject) -ne $want) { continue } if ($from) { $addr = '' try { $addr = [string]$it.SenderEmailAddress } catch {} if ($addr -and $addr -notlike "*$from*") { continue } } if (-not $best -or $it.ReceivedTime -gt $best.ReceivedTime) { $best = $it } } } return $best } try { if (-not $Url -or $Url -notmatch '^eule-outlook:(//)?(reply|forward|open|new)\b') { throw 'Missing or invalid link.' } $action = $Matches[2] $query = ($Url -split '\?', 2)[1] $q = [System.Web.HttpUtility]::ParseQueryString([string]$query) $ref = Resolve-MessageRef ([string]$q['m']) ([string]$q['s']) $entryId = $ref.EntryId $storeId = $ref.StoreId $replyAll = $q['all'] -eq '1' $hash = [string]$q['h'] $outlook = New-Object -ComObject Outlook.Application $ns = $outlook.GetNamespace('MAPI') $item = $null if ($action -eq 'new') { $reply = $outlook.CreateItem(0) # olMailItem $reply.To = Get-Addresses ([string]$q['to']) $cc = Get-Addresses ([string]$q['cc']) if ($cc) { $reply.CC = $cc } $subj = ([string]$q['subj']) -replace "[`r`n`t]", ' ' if ($subj.Length -gt 250) { $subj = $subj.Substring(0, 250) } $reply.Subject = $subj } elseif ($entryId) { try { if ($storeId) { $item = $ns.GetItemFromID($entryId, $storeId) } else { $item = $ns.GetItemFromID($entryId) } } catch { $item = $null } } if ($action -ne 'new') { if (-not $item) { $item = Find-ByHeaders $ns ([string]$q['subj']) ([string]$q['from']) } if (-not $item) { throw "Could not find the mail in Outlook:`n$($q['subj'])" } } if ($action -eq 'open') { $item.Display() try { $item.GetInspector.Activate() } catch {} return } if ($action -eq 'new') { # created above } elseif ($action -eq 'forward') { $reply = $item.Forward() $to = [string]$q['to'] if ($to -match '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') { $reply.To = $to } } else { $reply = if ($replyAll) { $item.ReplyAll() } else { $item.Reply() } } $reply.Display() # Outlook adds the signature here # Suggested text from the clipboard, only if it is exactly what the page copied $text = $null if ($hash -match '^[0-9a-f]{16}$') { try { $clip = Get-Clipboard -Raw } catch { $clip = $null } if ($clip) { $clip = $clip -replace "`r", '' if ((Get-ShortHash $clip) -eq $hash) { $text = $clip.TrimEnd() } } } if ($text) { $inserted = $false try { # Typing into the Word editor keeps Outlook's own compose font $doc = $reply.GetInspector.WordEditor $doc.Range(0, 0).InsertBefore(($text -replace "`n", "`r") + "`r") $inserted = $true } catch {} if (-not $inserted) { if ($reply.BodyFormat -eq 2) { $html = [System.Web.HttpUtility]::HtmlEncode($text) -replace "`n", '
' $body = $reply.HTMLBody $m = [regex]::Match($body, ']*>', 'IgnoreCase') if ($m.Success) { $reply.HTMLBody = $body.Insert($m.Index + $m.Length, "
$html

") } else { $reply.HTMLBody = "
$html

" + $body } } else { $reply.Body = $text + "`r`n`r`n" + $reply.Body } } } try { $reply.GetInspector.Activate() } catch {} } catch { Show-Error ("Could not open the mail in Outlook.`n`n" + $_.Exception.Message + "`n`nThe reply text is still on the clipboard - paste it with Ctrl+V.") }