Outlook.psm1
# vim: foldmethod=marker foldmarker={{{,}}}
set-strictMode -version latest
function send-outlookMail { # {{{
param(
[parameter(mandatory = $true)]
[string] $recipient
,
[parameter(mandatory = $true)]
[string] $subject
,
[parameter(mandatory = $true)]
[string] $body
,
[parameter(mandatory = $false)]
[string[]] $attachments
)
# $ol = get-activeObject outlook.application
$ol = get-msOfficeComObject outlook
$email = $ol.createItem(0) # 0 = olMailItem
$email.to = $recipient
$email.subject = $subject
$email.body = $body
foreach ($attachment in $attachments) {
$resolved_path = resolve-path $attachment
if (! (test-path $resolved_path)) {
write-host "Attachment $resolved_path was not found"
return
}
#
# For some reason, apparently, resolved path
# must be put into double quotes because otherwise,
# the error
# Value does not fall within the expected range.
# is thrown (which, imho, does not make lot of sense).
#
$null = $email.attachments.add("$resolved_path")
}
# $email.display()
$email.send()
} # }}}
function close-outlookWindows { # {{{
$ol = get-msOfficeComObject outlook
# 2021-11-29 / V.4: Loop multiple times
$insLoopAgain = $true
while ($insLoopAgain) {
write-host 'ins loop'
$insLoopAgain = $false
$ins_ = $ol.inspectors
foreach ($ins in $ins_) {
$insLoopAgain = $true
write-host " $($ins.caption)"
$ins.close(1) # 1 = olDiscard
}
}
# 2021-11-29 / V.4: Loop multiple times
$rmdLoopAgain = $true
while ($rmdLoopAgain) {
write-host 'rmd loop'
$rmdLoopAgain = $false
$rmd_ = $ol.reminders
foreach ($rmd in $rmd_) {
try {
# V0.6: catch "The property 'isVisible' cannot be found on this object."
$isVisible = $rmd.visible
}
catch {
if ($_.exception.hResult -eq 0x80131501) {
$isVisible = $false
}
else {
throw
}
}
if ($isVisible) {
$rmdLoopAgain = $true
write-host "Reminder: $($rmd.caption)"
$rmd.dismiss()
}
else {
# write-host "$($rmd.caption) is not visible"
}
}
}
} # }}}
function disable-outlookNotifications { # {{{
$regKeyOfficeRootV = get-msOfficeRegRoot
$regKeyOfficeRootV
set-itemProperty "$regKeyOfficeRootV\outlook\preferences" -name newMailDesktopAlerts -type dWord -value 0
} # }}}
function invoke-scriptBlockOnOutlookMails { # {{{
#
# invoke-scriptBlockOnOutlookMails — iterate over Outlook inbox and sent-mail folders,
# invoking a caller-supplied script block for each mail item.
#
# Script block signature:
#
# param($subject, $body, $bodyHtml, $time, $parties, $attachments, $headers, $isInbox)
#
# $subject — mail subject (string, may be $null)
# $body — plain-text body (string, may be $null)
# $bodyHtml — HTML body (string, may be $null)
# $time — for inbox: ReceivedTime (or CreationTime as fallback)
# for sent: SentOn
# $parties — array of [PSCustomObject] with the following properties:
# .name — display name (e.g. "John Doe")
# .address — SMTP email address
# .type — one of 'sender', 'to', 'cc', 'bcc'
#
# Inbox mails include the sender (type 'sender') plus
# To, CC and BCC recipients.
# Sent mails omit the sender (it's the mailbox owner)
# and only include To, CC and BCC recipients.
#
# $attachments — array of [PSCustomObject] with the following properties:
# .fileName — original file name (e.g. "report.pdf")
# .size — size in bytes
# .contentId — MIME content ID for inline images
# (the part after "cid:" in HTML src
# attributes); $null for regular file
# attachments
# .save — script block: & $att.save $path
# saves the attachment to disk via SaveAsFile
# .bytes — script block: & $att.bytes
# returns the raw content as byte array
# (via PR_ATTACH_DATA_BIN MAPI property;
# useful for database storage, vectorization etc.)
#
# $headers — array of [PSCustomObject] with the following properties:
# .name — header field name (e.g. "Received", "From")
# .value — decoded header value (RFC 2047 encoded words
# are decoded; folded continuation lines are
# joined into a single string)
#
# Extracted from the PR_TRANSPORT_MESSAGE_HEADERS MAPI
# property (0x007D001F). Headers with duplicate names
# (e.g. multiple Received: hops) appear as separate
# entries preserving their original order. May be an
# empty array for drafts or non-transported items.
#
# $isInbox — $true for inbox mails, $false for sent mails
#
param([parameter(mandatory)] [scriptBlock] $scriptBlock)
#
# TODO: Should also restore the value of errorActionPreference
# in case of an error.
#
$errorActionPreferenceOrig = $errorActionPreference
$errorActionPreference = 'Stop'
$null = add-type -assembly Microsoft.Office.Interop.Outlook
$outlook = new-object -comObject outlook.application
$mapi = $outlook.GetNameSpace('MAPI')
$olFolders = 'Microsoft.Office.Interop.Outlook.olDefaultFolders' -as [type]
function resolveAddressEntry($addressEntry, $type) { # {{{
#
# Resolve an AddressEntry to a PSCustomObject with name, address and type.
# Exchange recipients often expose an X500 DN instead of an
# SMTP address. Try to resolve it via GetExchangeUser().
#
$name = try { $addressEntry.Name } catch { $null }
$addr = try { $addressEntry.Address } catch { $null }
if ($addr -and $addr -match '^/o=') {
$exchUser = try { $addressEntry.GetExchangeUser() } catch { $null }
if ($exchUser) {
$addr = $exchUser.PrimarySmtpAddress
if (-not $name) { $name = $exchUser.Name }
}
}
return [PSCustomObject]@{ name = $name; address = $addr; type = $type }
} # }}}
function decodeRfc2047($s) { # {{{
#
# Decode RFC 2047 encoded words in a header value.
# Handles both quoted-printable (?Q?) and base64 (?B?) encodings.
# Example: =?iso-8859-1?Q?Ren=E9?= becomes René
#
return [regex]::Replace($s,
'=\?([^?]+)\?([QBqb])\?([^?]+)\?=',
{
param($m)
$charset = $m.Groups[1].Value
$enc = $m.Groups[2].Value.ToUpper()
$payload = $m.Groups[3].Value
if ($enc -eq 'Q') {
#
# Quoted-printable: underscores represent spaces,
# =XX represents a hex-encoded byte.
#
$payload = $payload -replace '_', ' '
$byteList = [System.Collections.Generic.List[byte]]::new()
$i = 0
while ($i -lt $payload.Length) { # {{{
if ($payload[$i] -eq '=' -and ($i + 2) -lt $payload.Length) {
$byteList.Add([convert]::ToByte($payload.Substring($i + 1, 2), 16))
$i += 3
}
else {
$byteList.Add([byte][char]$payload[$i])
$i++
}
} # }}}
$rawBytes = $byteList.ToArray()
}
else {
#
# Base64
#
$rawBytes = [convert]::FromBase64String($payload)
}
[System.Text.Encoding]::GetEncoding($charset).GetString($rawBytes)
}
)
} # }}}
function parseHeaders($raw) { # {{{
#
# Parse a raw RFC 5322 header block into an array of
# PSCustomObjects with .name and .value properties.
# Handles continuation lines (folded headers) and decodes
# RFC 2047 encoded words in values.
#
$result = @()
if (-not $raw) { return $result }
$lines = $raw -split '\r?\n'
$currentName = $null
$currentVal = $null
foreach ($line in $lines) { # {{{
#
# Skip blank lines (header/body separator or trailing).
#
if ($line -match '^\s*$') {
if ($currentName) {
$result += [PSCustomObject]@{
name = $currentName
value = decodeRfc2047 $currentVal.Trim()
}
$currentName = $null
$currentVal = $null
}
continue
}
#
# Continuation line: starts with whitespace.
#
if ($line -match '^\s+' -and $currentName) {
$currentVal += ' ' + $line.Trim()
continue
}
#
# New header line. Emit the previous one first.
#
if ($currentName) {
$result += [PSCustomObject]@{
name = $currentName
value = decodeRfc2047 $currentVal.Trim()
}
}
$colonIdx = $line.IndexOf(':')
if ($colonIdx -gt 0) {
$currentName = $line.Substring(0, $colonIdx)
$currentVal = $line.Substring($colonIdx + 1)
}
else {
#
# Malformed header line without colon — skip it.
#
$currentName = $null
$currentVal = $null
}
} # }}}
#
# Emit the last header if the string didn't end with a blank line.
#
if ($currentName) {
$result += [PSCustomObject]@{
name = $currentName
value = decodeRfc2047 $currentVal.Trim()
}
}
return $result
} # }}}
function iterateFolder($folderType, $scriptBlock) { # {{{
$folder = $mapi.GetDefaultFolder($folderType)
$isInbox = $folderType -eq $olFolders::olFolderInbox
foreach ($item in $folder.items) {
#
# Subject
#
$subject = try { $item.Subject } catch { $null }
#
# Body (plain text and HTML)
#
$body = try { $item.Body } catch { $null }
$bodyHtml = try { $item.HTMLBody } catch { $null }
#
# Time depends on the folder type.
#
if ($isInbox) {
$time = try {
if ($item.messageClass -eq 'IPM.Note') {
$item.ReceivedTime
}
else {
$item.CreationTime
}
}
catch {
try { $item.CreationTime } catch { $null }
}
}
else {
$time = try { $item.SentOn } catch { $null }
}
#
# Parties: a unified array of PSCustomObjects, each with
# name, address and type ('sender', 'to', 'cc' or 'bcc').
#
# Inbox mails include the sender (from $item.Sender) plus
# To, CC and BCC recipients from $item.Recipients.
#
# Sent mails omit the sender (it's always the mailbox owner)
# and only include To, CC and BCC recipients.
#
# Outlook recipient types: 1 = To, 2 = CC, 3 = BCC.
#
$parties = @()
if ($isInbox) {
$senderObj = try { resolveAddressEntry $item.Sender 'sender' } catch { $null }
if ($senderObj) { $parties += $senderObj }
}
$typeMap = @{ 1 = 'to'; 2 = 'cc'; 3 = 'bcc' }
try {
foreach ($rcpt in $item.Recipients) {
$rcptType = try { $typeMap[$rcpt.Type] } catch { 'to' }
$resolved = try { resolveAddressEntry $rcpt.AddressEntry $rcptType } catch { $null }
if ($resolved) { $parties += $resolved }
}
}
catch { }
#
# Attachments: an array of PSCustomObjects with fileName, size,
# contentId and two script blocks (save and bytes) for lazy extraction.
# Inline images have a non-null contentId matching the cid: reference
# in the HTML body; regular file attachments have contentId = $null.
#
$attachments = @()
try {
foreach ($att in $item.Attachments) {
$attName = try { $att.FileName } catch { $null }
$attSize = try { $att.Size } catch { 0 }
$attContentId = try {
$att.PropertyAccessor.GetProperty('http://schemas.microsoft.com/mapi/proptag/0x3712001F')
}
catch { $null }
$attachments += [PSCustomObject]@{
fileName = $attName
size = $attSize
contentId = $attContentId
save = { param($path) $att.SaveAsFile($path) }.GetNewClosure()
bytes = { $att.PropertyAccessor.GetProperty(
'http://schemas.microsoft.com/mapi/proptag/0x37010102'
) }.GetNewClosure()
}
}
}
catch { }
#
# Transport headers: extract the raw RFC 5322 header block
# via PR_TRANSPORT_MESSAGE_HEADERS and parse it into an array
# of PSCustomObjects with .name and .value.
#
$rawHeaders = try {
$item.PropertyAccessor.GetProperty(
'http://schemas.microsoft.com/mapi/proptag/0x007D001F'
)
}
catch { $null }
$headers = parseHeaders $rawHeaders
& $scriptBlock $subject $body $bodyHtml $time $parties $attachments $headers $isInbox
}
$errorActionPreference = $errorActionPreferenceOrig
} # }}}
iterateFolder $olFolders::olFolderInbox $scriptBlock
iterateFolder $olFolders::olFolderSentMail $scriptBlock
} # }}}
function save-outlookMails { # {{{
[cmdletBinding()]
param(
[switch] $saveAttachments
)
$emailsOutDir = join-path $home 'saved-emails'
$null = new-item -itemType directory -path $emailsOutDir -force
invoke-scriptBlockOnOutlookMails -scriptBlock {
param($subject, $body, $bodyHtml, $time, $parties, $attachments, $headers, $isInbox)
if ($verbosePreference -eq 'Continue') { # {{{ Write sender and recipient information in verbose mode
$prefixMap = @{ to = 'to: '; cc = 'cc: '; bcc = 'bcc: ' }
foreach ($party in $parties) {
$prefix = $prefixMap[$party.type]
if ($prefix) {
write-verbose " $prefix$($party.name) <$($party.address)>"
}
}
foreach ($att in $attachments) {
$label = if ($att.contentId) { 'inline' } else { 'attachment' }
write-verbose " ${label}: $($att.fileName) ($($att.size) bytes)"
}
} # }}}
#
# Build a date prefix from the time stamp.
#
$datePrefix = if ($time) {
$time.ToString('yyyy-MM-dd_HHmm_')
}
else {
'unknown_'
}
#
# Remove characters that cannot be used in NTFS file names.
# Also strip trailing dots and spaces — NTFS silently removes
# these from directory names, causing path mismatches.
# Truncate to 120 characters to avoid exceeding the NTFS
# 260-character path limit when attachment filenames are added.
#
$sanitizedSubject = if ($subject) {
$s = (($subject -replace '[\\/:*?"<>|\[\]]', '_') -replace '\s+', ' ').TrimEnd(' .')
if ($s.Length -gt 120) { $s.Substring(0, 120).TrimEnd(' .') } else { $s }
}
else {
'no-subject'
}
#
# Create a subfolder for this mail and save body.txt / body.html into it.
#
$folderName = $datePrefix + $sanitizedSubject
$folderPath = join-path $emailsOutDir $folderName
$null = new-item -itemType directory -path $folderPath -force
write-host "Saving to $folderName"
$body | out-file -filePath (join-path $folderPath 'body.txt') -encoding utf8
#
# Distinguish inline images (referenced by cid: in HTML) from
# regular file attachments.
#
$inlineAtts = @($attachments | where-object { $_.contentId })
$fileAtts = @($attachments | where-object { -not $_.contentId })
#
# Save inline images and rewrite cid: references in the HTML body.
# Inline images are saved regardless of the value of -saveAttachments
# parameter so that body.html renders correctly.
# Note: SaveAsFile can throw COMException on embedded .msg files
# and OLE objects — catch and warn rather than aborting the script.
#
foreach ($att in $inlineAtts) {
$attPath = join-path $folderPath $att.fileName
try { & $att.save $attPath }
catch { write-warning " Failed to save inline image $($att.fileName): $_" }
if ($bodyHtml) {
$bodyHtml = $bodyHtml -replace
"cid:$([regex]::Escape($att.contentId))",
$att.fileName
}
}
$bodyHtml | out-file -filePath (join-path $folderPath 'body.html') -encoding utf8
#
# Save regular (non-inline) attachments into the same subfolder
# (opt-in via -saveAttachments).
# Note: SaveAsFile can throw COMException on embedded .msg files
# and OLE objects — catch and warn rather than aborting the script.
#
if ($saveAttachments -and $fileAtts) {
foreach ($att in $fileAtts) {
$attPath = join-path $folderPath $att.fileName
write-host " attachment: $($att.fileName)"
try { & $att.save $attPath }
catch { write-warning " Failed to save attachment $($att.fileName): $_" }
}
}
#
# Save metadata.json with subject, time, isInbox, parties
# and parsed transport headers.
#
$timeStr = if ($time) { $time.ToString('o') } else { $null }
$metadata = [PSCustomObject]@{
subject = $subject
time = $timeStr
isInbox = $isInbox
parties = @($parties | where-object { $_ } | foreach-object {
[PSCustomObject]@{ name = $_.name; address = $_.address; type = $_.type }
})
headers = @($headers | where-object { $_ } | foreach-object {
[PSCustomObject]@{ name = $_.name; value = $_.value }
})
}
$metadata |
convertTo-json -depth 4 |
out-file -filePath (join-path $folderPath 'metadata.json') -encoding utf8
}
} # }}}