Search notes:

Powershell module: Outlook

Functions

send-outlookMail Send an email with Outlook from PowerShell.
close-outlookWindows Closes (or at least tries to close) outlook items and to dismiss active reminders.
disable-outlookNotifications Do not show the distracting notificaction when an email message arrives.
invoke-scriptBlockOnOutlookMails Invoke a script block for each mail in the inbox and the sent folder
save-outlookMails Uses invoke-scriptBlockOnOutlookMails to export mails (optionally with attachments)

Source code

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

   }
} # }}}
Github repository ps-modules-Outlook, path: /Outlook.psm1

Outlook.psd1

@{
   RootModule        = 'Outlook.psm1'
   ModuleVersion     = '0.7'
   RequiredModules   = @(
      'MS-Office'
   )
   FunctionsToExport = @(
     'send-outlookMail',
     'close-outlookWindows',
     'invoke-scriptBlockOnOutlookMails',
     'disable-outlookNotifications',
     'save-outlookMails'
    )
}
Github repository ps-modules-Outlook, path: /Outlook.psd1

History

V.2 Use get-msOfficeComObject to obtain an Outlook COM Object.
V.3 Add close-outlookWindows
V.4 close-outlookWindows: re-start iteration loop as long as inspectors and/or reminders were close (2021-11-29)
V.5 disable-outlookNotifications
V.6 Catch error message The property 'isVisible' cannot be found on this object.
V.7 Added invoke-scriptBlockOnOutlookMails and save-outlookMails. The latter uses the former to save all mails in the inbox and the sent folder, optionally including attachments.

TODO

With the change of V0.6, is the $rmdLoopAgain variable and associated «loop again» code still necessary?

See also

René's simple PowerShell modules

Index

Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 8 attempt to write a readonly database in /home/httpd/vhosts/renenyffenegger.ch/php/web-request-database.php:78 Stack trace: #0 /home/httpd/vhosts/renenyffenegger.ch/php/web-request-database.php(78): PDOStatement->execute(Array) #1 /home/httpd/vhosts/renenyffenegger.ch/php/web-request-database.php(30): insert_webrequest_('/notes/Windows/...', 1788601375, '216.73.216.30', 'Mozilla/5.0 App...', NULL) #2 /home/httpd/vhosts/renenyffenegger.ch/httpsdocs/notes/Windows/PowerShell/modules/personal/Outlook/index(662): insert_webrequest() #3 {main} thrown in /home/httpd/vhosts/renenyffenegger.ch/php/web-request-database.php on line 78