OutlookMonitor.ps1
<#PSScriptInfo .VERSION 5.1.3 .GUID 992ee67a-f846-4563-9a00-18291f13798c .AUTHOR julianzs .COMPANYNAME .COPYRIGHT .TAGS .LICENSEURI .PROJECTURI .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES .PRIVATEDATA #> <# .DESCRIPTION To install, run PowerShell.exe -executionpolicy bypass -command "& .\OutlookMonitor.ps1 -install" in an administrative command prompt window" #> [CmdletBinding()] Param( #Do not hide Powershell console so you can see error and debug output [switch]$doNotHideConsole, #Install script and create scheduled task [switch]$install, #Uninstall Install script and remove scheduled task [switch]$uninstall ) Begin { If ($PSBoundParameters['Debug']) { $DebugPreference = 'Continue' } ##########Types and objects for generating actionable messages########## #Template for Actionable Message JSON $jsonTemplate = @' { "type": "AdaptiveCard", "originator": "c0c27934-de27-4cbf-8624-d048c9fe505b", "body": [ { "type": "Container", "spacing": "None", "style": "emphasis", "items": [ { "type": "ColumnSet", "columns": [ { "type": "Column", "items": [ { "type": "TextBlock", "text": "Please help us make meetings better at Microsoft", "wrap": true, "size": "Large" } ], "width": "stretch", "padding": "None" }, { "type": "Column", "items": [ { "type": "Image", "id": "4b3986aa-ca62-8df9-f4c9-5d9bc8585978", "url": "https://docs.microsoft.com/en-us/workplace-analytics/images/icon-queries.png", "horizontalAlignment": "Right" } ], "width": "auto", "padding": "None", "verticalContentAlignment": "Center" } ], "padding": "None" } ], "padding": "Default", "id": "header" }, { "type": "Container", "items": [ { "type": "TextBlock", "size": "Medium", "weight": "Bolder", "text": "Which of these meetings did you or will you attend?", "wrap": true } ], "padding": "Default", "spacing": "None", "separator": true, "id": "question" }, { "type": "Container", "items": [ { "type": "Input.ChoiceSet", "id": "meetingList", "choices": [ { "title": "Cat", "value": "Dog" }, { "title": "Cat2", "value": "Dog2" } ], "style": "expanded", "isMultiSelect": true, "isRequired": true }, { "type": "ColumnSet", "id": "2b0cc00e-5f5c-0779-597f-a0511b95842f", "columns": [ { "type": "Column", "id": "93ee484f-6c32-2b35-f9ff-fb2b1d51a22a", "padding": "None", "width": "auto", "items": [ { "type": "ActionSet", "actions": [ { "type": "Action.Http", "title": "Submit", "method": "POST", "url": "https://prod-02.westcentralus.logic.azure.com:443/workflows/9dbce91ff591424889c7c5820b33888d/triggers/manual/paths/invoke?api-version=2016-06-01&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=FMv4psfYoQVgasWtECMDiGMuJ6qPhUjCxmQ6V6RFup4", "body":"", "headers": [ { "name": "Authorization", "value":"" } ] } ], "id": "submitButton" } ], "horizontalAlignment": "Center" } ], "padding": "None" }, { "type": "Container", "id": "707c08bc-3c5d-0671-4f63-ae2ff4209e5e", "padding": "None", "items": [ { "type": "TextBlock", "id": "b902c86f-851a-a420-4f30-a57fcc87f2a9", "text": "Our goal as the Workplace Intelligence Team is to help you be more productive. To do that, we are gathering information on how you use your calendar and which meetings you attend. We'll use it to help you and our customers optmize how much time is spent in meetings and focusing on personal work. Thanks for taking the time to help.", "wrap": true } ], "separator": true, "spacing": "ExtraLarge" } ], "padding": "Default", "spacing": "None", "id": "meetingChoices", "separator": true } ], "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "version": "1.0", "padding": "None", "@type": "AdaptiveCard", "@context": "http://schema.org/extensions", "fallbackText": "If you can't see this message on your mobile device, please view it in desktop Outlook. This is a known issue that will be fixed soon. " } '@ #Header prepended to JSON to generate HTML message $HTMLHeader = @" <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <script type="application/adaptivecard+json"> "@ #Footer appended to JSON to generate HTML message $HTMLFooter = @" </script> </head> <body> </body> </html> "@ ##########Functions for generating actionable messages########## #Get calendar folder object Function Get-OutlookCalendarFolder { #Add-type -assembly "Microsoft.Office.Interop.Outlook" | out-null $olFolders = "Microsoft.Office.Interop.Outlook.OlDefaultFolders" -as [type] #$outlook = new-object -comobject outlook.application $namespace = $outlook.GetNameSpace("MAPI") $namespace.getDefaultFolder($olFolders::olFolderCalendar) } #Get Outlook calendar appointments for today Function Get-OutlookCalendarAppointmentsToday { $folder = Get-OutlookCalendarFolder #Calculate today as 24 hours starting at midnight of the current day $Start = (Get-Date -Hour 0 -Minute 00 -Second 00).ToShortDateString() + " 00:00" $End = (Get-Date).AddDays(+1).ToShortDateString() + " 00:00" #$filter = "[MessageClass]='IPM.Appointment' AND [Start] >= '$Start' AND [End] <= '$End'" $filter = "[MessageClass]='IPM.Appointment' AND (([Start] >= '$Start' AND [End] <= '$End') OR ([Start] <= '$Start' AND [End] >= '$Start') OR ([Start] <= '$end' AND [End] >= '$start'))" Write-Debug -Message "Outlook calendar filter being used is $($filter)" $Appointments = $folder.Items Release-OutlookComObject -comObject $folder $Appointments.IncludeRecurrences = $true $Appointments.Sort("[Start]") $Appointments.restrict($filter) #Write-Debug -Message "Appointments found on calendar $($Appointments | Select-Object subject,start,end)" } #Get overlapping meetings in today's appointments ###BUGBUG I don't think it captures meetings that don't start and end today properly. Function Get-OverlappingMeetings { Param( $appointmentList ) $appointmentCount = $appointmentList.count #Iterate though each appointment and check for overlap with other appointments. http://wiki.c2.com/?TestIfDateRangesOverlap is the algorithm I used $overlappingMeetingArrayList = @(@()) for($i=0; $i -lt $appointmentCount; $i++) { $overlappingMeetingArray = @() #put the first item in the overlapping array and add any overlapping items. If none are added and count remains one, I assume there's no overlap. $overlappingMeetingArray += $appointmentList[$i] #If this isn't the first appointment in the list, check if its start and end times match the previous one. If the do, skip any further comparison as the prevoius one captured all the overlaps. if(!(($i -gt 0) -and ($appointmentList[$i].start -eq $appointmentList[$i-1].start) -and ($appointmentList[$i].end -eq $appointmentList[$i-1].end))) { for($j=0; $j -lt $appointmentCount; $j++) { #Make sure you're not comparing the item to itself. if($appointmentList[$i] -ne $appointmentList[$j]) { if(($appointmentList[$i].start -lt $appointmentList[$j].end) -and ($appointmentList[$j].start -lt $appointmentList[$i].end)) { write-debug "$($appointmentList[$i].start)-$($appointmentList[$i].end) overlaps with $($appointmentList[$j].start)-$($appointmentList[$j].end)" $overlappingMeetingArray += $appointmentList[$j] } else { write-debug "$($appointmentList[$i].start)-$($appointmentList[$i].end) does not overlap with $($appointmentList[$j].start)-$($appointmentList[$j].end)" } } } #If there's only one item, it's the meeting I'm comparing to the others. If more, there were overlaps. if($overlappingMeetingArray.count -gt 1) { $overlappingMeetingArrayList += ,@($overlappingMeetingArray) } } } ,$overlappingMeetingArrayList } #Get overlapping meetings in today's appointments ###BUGBUG I don't think it captures meetings that don't start and end today properly. Function Get-RandomMeetingAndOverlaps { Param( $appointmentList ) #Select a random appointment $randomMeetingAndOverlaps = @() $randomMeetingAndOverlaps += $appointmentList | get-random Write-Debug "Meeting $($randomMeetingAndOverlaps.subject) selected to find overlaps" #Iterate though each appointment and check for overlap with other appointments. http://wiki.c2.com/?TestIfDateRangesOverlap is the algorithm I used $appointmentCount = $appointmentList.count foreach($appointment in $appointmentList) { #Make sure you're not comparing the item to itself. if($randomMeetingAndOverlaps[0]-ne $appointment) { if(($randomMeetingAndOverlaps[0].start -lt $appointment.end) -and ($appointment.start -lt $randomMeetingAndOverlaps[0].end)) { write-debug "$($randomMeetingAndOverlaps[0].start)-$($randomMeetingAndOverlaps[0].end) overlaps with $($appointment.start)-$($appointment.end)" $randomMeetingAndOverlaps += $appointment } else { write-debug "$($randomMeetingAndOverlaps[0].start)-$($randomMeetingAndOverlaps[0].end) does not overlap with $($appointment.start)-$($appointment.end)" } } } $randomMeetingAndOverlaps } #Get owner of the mailbox from an item <# Function Get-MailboxOwner { Param( $appointment ) if($appointment.recurrence #> #Generate Actionable Message Body from a list of appointments Function New-ActionableMessageBody { Param ( $jsonTemplate, $overlappingMeetingList, $HTMLHeader, $HTMLFooter ) #Create a unique ID for the message that will be included in all items so I can correlate them later $sessionID = (new-guid).Guid #Iterate through meetings and create a choice to appear in the Adaptive card and an object with the full item metadata $overlappingMeetingListforJson = @() $overlappingMeetingChoiceListforJson = @() foreach($overlappingMeeting in $overlappingMeetingList) { $overlappingMeetingChoiceListforJson += [pscustomobject]@{ Title = "$($overlappingMeeting.subject) ($($overlappingMeeting.Start.DayOfWeek) $($overlappingMeeting.Start.ToShortTimeString())-$($overlappingMeeting.end.ToShortTimeString()))" Value = $overlappingMeeting.GlobalAppointmentID } $overlappingMeetingListforJson += [pscustomobject]@{ SessionID = $sessionID Subject = $overlappingMeeting.Subject Start = $overlappingMeeting.Start End = $overlappingMeeting.End MailboxOwner = $overlappingMeeting.session.CurrentUser.name Attachments = $overlappingMeeting.Attachments Categories = $overlappingMeeting.Categories Companies = $overlappingMeeting.Companies ConversationIndex = $overlappingMeeting.ConversationIndex CreationTime = $overlappingMeeting.CreationTime EntryID = $overlappingMeeting.EntryID Importance = $overlappingMeeting.Importance LastModificationTime = $overlappingMeeting.LastModificationTime MessageClass = $overlappingMeeting.MessageClass NoAging = $overlappingMeeting.NoAging Sensitivity = [Microsoft.Office.Interop.Outlook.OlSensitivity].GetEnumName($overlappingMeeting.Sensitivity) Size = $overlappingMeeting.Size UnRead = $overlappingMeeting.UnRead AllDayEvent = $overlappingMeeting.AllDayEvent BusyStatus = [Microsoft.Office.Interop.Outlook.OlBusyStatus].GetEnumName($overlappingMeeting.BusyStatus) Duration = $overlappingMeeting.Duration IsOnlineMeeting = $overlappingMeeting.IsOnlineMeeting IsRecurring = $overlappingMeeting.IsRecurring Location = $overlappingMeeting.Location MeetingStatus = [Microsoft.Office.Interop.Outlook.OlMeetingStatus].GetEnumName($overlappingMeeting.MeetingStatus) NetMeetingAutoStart = $overlappingMeeting.NetMeetingAutoStart NetMeetingOrganizerAlias = $overlappingMeeting.NetMeetingOrganizerAlias NetMeetingServer = $overlappingMeeting.NetMeetingServer NetMeetingType = $overlappingMeeting.NetMeetingType OptionalAttendees = $overlappingMeeting.OptionalAttendees Organizer = $overlappingMeeting.Organizer Recipients = $overlappingMeeting.Recipients RecurrenceState = [Microsoft.Office.Interop.Outlook.OlRecurrenceState ].GetEnumName($overlappingMeeting.RecurrenceState) ReminderMinutesBeforeStart = $overlappingMeeting.ReminderMinutesBeforeStart ReminderOverrideDefault = $overlappingMeeting.ReminderOverrideDefault ReminderPlaySound = $overlappingMeeting.ReminderPlaySound ReminderSet = $overlappingMeeting.ReminderSet ReminderSoundFile = $overlappingMeeting.ReminderSoundFile ReplyTime = $overlappingMeeting.ReplyTime RequiredAttendees = $overlappingMeeting.RequiredAttendees Resources = $overlappingMeeting.Resources ResponseRequested = $overlappingMeeting.ResponseRequested ResponseStatus = [Microsoft.Office.Interop.Outlook.OlResponseStatus].GetEnumName($overlappingMeeting.ResponseStatus) Links = $overlappingMeeting.Links GlobalAppointmentID = $overlappingMeeting.GlobalAppointmentID StartUTC = $overlappingMeeting.StartUTC EndUTC = $overlappingMeeting.EndUTC StartInStartTimeZone = $overlappingMeeting.StartInStartTimeZone EndInEndTimeZone = $overlappingMeeting.EndInEndTimeZone StartTimeZone = $overlappingMeeting.StartTimeZone EndTimeZone = $overlappingMeeting.EndTimeZone ConversationID = $overlappingMeeting.ConversationID DoNotForwardMeeting = $overlappingMeeting.DoNotForwardMeeting FOthersAppt = $overlappingMeeting.FOthersAppt } Release-OutlookComObject -comObject $overlappingMeeting } #Add an option for not attending any of the meetings $overlappingMeetingChoiceListforJson += [pscustomobject]@{ Title = "None of these" Value = "No conflicting meeting attended" } #add choices to JSON $json = Convertfrom-Json -InputObject $jsonTemplate -Verbose $json.body[2].items[0].choices = $overlappingMeetingChoiceListforJson #Add meeting list with full details to JSON $HTTPPOSTBody = @() $HTTPPOSTBody += [pscustomobject]@{ attendedItemID = "{{meetingList.value}}" } $HTTPPOSTBody += ,@($overlappingMeetingListforJson) #$json.body[2].items[1].actions[0].body = $HTTPPOSTBody | ConvertTo-Json $json.body[2].items[1].columns[0].items[0].actions[0].body = $HTTPPOSTBody | ConvertTo-Json $HTMLBody = $HTMLheader + ($json | ConvertTo-Json -Depth 100) + $HTMLFooter $HTMLBody } #Create new actionable message and send it Function Send-ActionableMessage { Param ( $jsonTemplate, $overlappingMeetingList, $HTMLHeader, $HTMLFooter ) #$outlook = new-object -comobject outlook.application Write-Debug "Creating actionable message" $Mail = $Outlook.CreateItem(0) $Mail.To = $Mail.parent.Store.DisplayName Write-Debug "Recipient $($Mail.To)" $Mail.Subject = "Could you please answer one question to help Workplace Intelligence make meetings better at Microsoft?" Write-Debug "Recipient $($Mail.subject)" $Mail.HTMLBody = New-ActionableMessageBody -jsonTemplate $jsonTemplate -overlappingMeetingList $overlappingMeetingList -HTMLHeader $HTMLHeader -HTMLFooter $HTMLFooter Write-Debug "Sending actionable message" $mail.send() Release-OutlookComObject -comObject $Mail } #Generate a new actionable message Function New-ActionableMessage { #$overlappingMeetingArrayList = @(@()) $AppointmentList = Get-OutlookCalendarAppointmentsToday $overlappingMeetingArray = Get-RandomMeetingAndOverlaps -appointmentList $AppointmentList if($overlappingMeetingArray.count -eq 0) { Write-Debug "There are no meetings, not sending an actionable message" } else { Write-Debug "Selected the following meetings for Actionable Message:" foreach ($overlappingMeeting in $overlappingMeetingArray) { Write-Debug "$($overlappingMeeting.subject), $($overlappingMeeting.globalappointmentid)" } Send-ActionableMessage -jsonTemplate $jsonTemplate -overlappingMeetingList $overlappingMeetingArray -HTMLHeader $HTMLHeader -HTMLFooter $HTMLFooter } <#$overlappingMeetingArrayList += Get-OverlappingMeetings -appointmentList $AppointmentList if($overlappingMeetingArrayList.count -eq 0) { Write-Debug "There are no overlapping meetings, not sending an actionable message" } else { if($overlappingMeetingArrayList.count -eq 1) { Write-Debug "One overlapping meeting found, sending actionable message" $overlappingMeetingArray = $overlappingMeetingArrayList[0] } else { Write-Debug "Multiple overlapping meetings found, choosing one at random and sending actionable message" $overlappingMeetingArray = $overlappingMeetingArrayList[(get-random -Minimum 0 -Maximum $overlappingMeetingArrayList.count)] } Write-Debug "Selected the following meetings for Actionable Message:" foreach ($overlappingMeeting in $overlappingMeetingArray) { Write-Debug "$($overlappingMeeting.subject), $($overlappingMeeting.globalappointmentid)" } Send-ActionableMessage -jsonTemplate $jsonTemplate -overlappingMeetingList $overlappingMeetingArray -HTMLHeader $HTMLHeader -HTMLFooter $HTMLFooter } #> } #Check time of day and see if it's the scheduled time or after. If so, send the message. Function Send-ActionableMessageOnSchedule { $actionableMessageSendTime = "2:00 PM" $currentDate = get-date $calendarFolder = Get-OutlookCalendarFolder $lastActionableMessageSendDate = $calendarFolder.description #$lastActionableMessageSendDate = [System.Environment]::getEnvironmentVariable('lastActionableMessageSendDate',[System.EnvironmentVariableTarget]::User) Write-Debug "Last actionable message was sent on date $lastActionableMessageSendDate and messages are configured to be sent daily at $actionableMessageSendTime" if(($currentDate.ToShortDateString() -ne $lastActionableMessageSendDate) -and ($currentDate -ge (get-date $actionableMessageSendTime))) { Write-Debug "Current date and time is $currentDate, sending actionable message" New-ActionableMessage $calendarFolder.description= $currentDate.ToShortDateString() #[System.Environment]::SetEnvironmentVariable('lastActionableMessageSendDate', ($currentDate.ToShortDateString()), [System.EnvironmentVariableTarget]::User) } else { Write-Debug "Current date and time is $currentDate, will not send actionable message" } } ##########Types and objects for Outlook Monitor########### #Add types to get which window is in focus to detect if Outlook is in use Add-Type @" using System; using System.Runtime.InteropServices; public class UserWindows { [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); } "@ #Add types to hide the console window Add-Type -Name Window -Namespace Console -MemberDefinition ' [DllImport("Kernel32.dll")] public static extern IntPtr GetConsoleWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, Int32 nCmdShow); ' #Add type to get machine idle time to determine if user walked away but left Outlook open Add-Type @' using System; using System.Diagnostics; using System.Runtime.InteropServices; namespace PInvoke.Win32 { public static class UserInput { [DllImport("user32.dll", SetLastError=false)] private static extern bool GetLastInputInfo(ref LASTINPUTINFO plii); [StructLayout(LayoutKind.Sequential)] private struct LASTINPUTINFO { public uint cbSize; public int dwTime; } public static DateTime LastInput { get { DateTime bootTime = DateTime.UtcNow.AddMilliseconds(-Environment.TickCount); DateTime lastInput = bootTime.AddMilliseconds(LastInputTicks); return lastInput; } } public static TimeSpan IdleTime { get { return DateTime.UtcNow.Subtract(LastInput); } } public static int LastInputTicks { get { LASTINPUTINFO lii = new LASTINPUTINFO(); lii.cbSize = (uint)Marshal.SizeOf(typeof(LASTINPUTINFO)); GetLastInputInfo(ref lii); return lii.dwTime; } } } } '@ #Create Outlook state object that will maintain the Outlook status $Script:OutlookState = [pscustomobject]@{ #Item info ItemSubject = $null ItemToRecipientList = $null ItemCCRecipientList = $null ItemBCCRecipientList = $null ItemSender = $null ItemType = $null ItemBodyLength = 0 ItemImportance = $null ItemSize = 0 ItemCreationTime = $null ItemFolderName = $null ItemVisibleAttachmentCount = 0 ItemEmbeddedAttachmentCount = 0 ItemMessageID = $null #Machine user UserName = $env:USERNAME #Outlook state info DateTimeUTC = $null OutlookInFocus = $false UserIsReading = $false UserIsEditing = $false } ##########Functions concerning getting machine and application idle/in use state########## #Monitors Outlook state Function Get-OutlookUsageState { $timer.Stop() if(!$Outlook) { Write-Debug "There's no Outlook COM object, checking if Outlook is running and retrieving Outlook COM object if so." $script:outlook = Get-OutlookComObject } $script:OutlookState.DateTimeUTC = (get-date).ToUniversalTime() #Check if Outlook is the window in focus and the machine is not idle to determine if Outlook is in use if((Get-OutlookFocusState) -and !(Get-MachineIdleState -timeLimit 120)) { $OutlookState.OutlookInFocus = $true #Get the active Outlook window (assuming non-active windows likely aren't in use)" try { $activeWindow = $outlook.ActiveWindow() } catch [System.Management.Automation.MethodInvocationException] { #If Outlook is closed and reopened, get a new com object Write-Debug "Caught an exception using an Outlook COM object, will get a new one." $script:outlook = new-object -comobject outlook.application $activeWindow = $outlook.ActiveWindow() } #Retreive info about the open window and any items within it switch ($activeWindow.class) { 34 {Get-OutlookExplorerState -explorer $activeWindow} 35 {Get-OutlookInspectorState -inspector $activeWindow} } #Send the log file based on the schedule Send-LogFileOnSchedule #Send the actionable message based on the schedule Send-ActionableMessageOnSchedule Release-OutlookComObject($activeWindow) } else { #Outlook is not in use $OutlookState.OutlookInFocus = $false $OutlookState.UserIsEditing = $false $OutlookState.UserIsReading = $false Clear-OutlookItemInfo } #Output current state to a CSV file $OutlookState | Export-Csv $logFilePath -Append Update-OutlookMonitorGUI -OutlookState $OutlookState $timer.Start() } #Check if the Outlook Window is in focus on the machine, get the foreground window and map that to process name to confirm it's Outlook. Function Get-OutlookFocusState { $processID = 0 try { $ActiveHandle = [UserWindows]::GetForegroundWindow() $null = [UserWindows]::GetWindowThreadProcessId($ActiveHandle,[ref]$processID) if((Get-Process -id $processID).name -eq "Outlook") { $true } else { $false } } catch { Write-Error "Failed to get active Window details. More Info: $_" } } #Check if machine is idle Function Get-MachineIdleState { Param( [int]$timeLimit ) $idleTime = [PInvoke.Win32.UserInput]::IdleTime.TotalSeconds if($idleTime -gt $timeLimit) { $true } else { $false } } ##########Functions concerning getting Outlook folder and item info########## #Get recipient Info Function Get-OutlookItemRecipientInto { Param( $outlookItemRecipientList ) #####BUGBUG##### it counts a DG as one recipient, can we fix that? Not for external DGs, but maybe internal foreach($outlookItemRecipient in $OutlookItemRecipientList) { if($outlookItemRecipient.type -eq [Microsoft.Office.Interop.Outlook.OlMailRecipientType]::Olto) { $OutlookState.ItemToRecipientList += "$($outlookItemRecipient.name);" #$OutlookState.ItemToRecipientCount += Get-OutlookItemRecipientCount -outlookItemRecipient $outlookItemRecipient } if($outlookItemRecipient.type -eq [Microsoft.Office.Interop.Outlook.OlMailRecipientType]::OlCC) { $OutlookState.ItemCCRecipientList += "$($outlookItemRecipient.name);" #$OutlookState.ItemCCRecipientCount += Get-OutlookItemRecipientCount -outlookItemRecipient $outlookItemRecipient } if($outlookItemRecipient.type -eq [Microsoft.Office.Interop.Outlook.OlMailRecipientType]::OlBCC) { $OutlookState.ItemBCCRecipientList += "$($outlookItemRecipient.name);" #$OutlookState.ItemBCCRecipientCount += Get-OutlookItemRecipientCount -outlookItemRecipient $outlookItemRecipient } Release-OutlookComObject($outlookItemRecipient) } } #Get metadata from an Outlook item Function Get-OutlookItemInfo { Param ( $outlookItem ) #If the message ID hasn't changed, the same item is in focus, no need to retreive the data again. If it's "saved", it has been modified since last save so capture that. write-debug "Checking to see if item has changed by comparing messageid of current and previous item and checking for saved state." Write-Debug "Message ID at time of last check: $($OutlookState.ItemMessageID)" Write-Debug "Message ID at current check: $($outlookitem.PropertyAccessor.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x1035001F"))" Write-Debug "Message has not been changed since last save: $($outlookItem.saved)" if($OutlookState.ItemMessageID -ne $outlookitem.PropertyAccessor.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x1035001F") -or !$outlookItem.saved) { Write-Debug "Message has been determined to need to be checked again, either because it's a new message ID or it's changed since last save" Clear-OutlookItemInfo $OutlookState.ItemMessageID = $outlookitem.PropertyAccessor.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x1035001F") #Get recipient info $recipients = $outlookItem.recipients Get-OutlookItemRecipientInto -outlookItemRecipientList $recipients Release-OutlookComObject($recipients) #Fill out remaining message properties $OutlookState.ItemSender = $outlookItem.Sender.name $OutlookState.ItemBodyLength = $outlookItem.body.length ###BUGBUG#### see if you can get the length of the most recent reply $OutlookState.ItemFolderName = $outlookItem.parent.FullFolderPath $OutlookState.ItemImportance = [Microsoft.Office.Interop.Outlook.OlImportance]$outlookItem.importance $OutlookState.ItemcreationTime = $outlookItem.creationTime $OutlookState.ItemSize = $outlookItem.size $OutlookState.ItemSubject = $OutlookItem.Subject $OutlookState.ItemType = [Microsoft.Office.Interop.Outlook.OlObjectClass]$outlookItem.class Get-OutlookAttachmentInfo -item $outlookItem } else { Write-Debug "Message has been determined to not need to be checked again, message ID is the same or it's an item being composed that hasn't been changed since last save" } } #Gets counts of attachment on an item Function Get-OutlookAttachmentInfo { Param( $item ) #Loop through each attachmetn and look at various flags to determine if it's a visible attachment or embedded foreach($attachment in $item.attachments) { $attachFlags = $attachment.PropertyAccessor.GetProperty('http://schemas.microsoft.com/mapi/proptag/0x37140003') $attachMethod = $attachment.PropertyAccessor.GetProperty('http://schemas.microsoft.com/mapi/proptag/0x37050003') $attachContentID = $attachment.PropertyAccessor.GetProperty('http://schemas.microsoft.com/mapi/proptag/0x3712001F') $attachContentLocation = $attachment.PropertyAccessor.GetProperty('http://schemas.microsoft.com/mapi/proptag/0x3713001F') Write-Debug "Attachment name: $($attachment.filename)" write-debug "PR_ATTACH_CONTENT_ID: $attachContentID" write-debug "PR_ATTACH_CONTENT_LOCATION: $attachContentLocation" write-debug "attach flags: $attachFlags" write-debug "attach method: $attachmethod" write-debug "attach type: $($attachment.type)" #Cloudy attachment: $attachContentID -and -not ($attachFlags -eq 4 -and $attachMethod -eq 7) #Other two checks from http://social.msdn.microsoft.com/Forums/en-SG/outlookdev/thread/4e005509-56de-47c0-99cb-b5ac5a972789 if(($attachContentID -ne "" -and $outlookItem.HTMLBody.Contains($attachContentID)) -and !($attachFlags -eq 4 -and $attachMethod -eq 7) -or ($attachMethod -eq 6)) { $outlookState.ItemEmbeddedAttachmentCount++ Write-Debug "Attachment determined to be embedded" } else { $outlookState.ItemVisibleAttachmentCount++ Write-Debug "Attachment determined to be visible" } Release-OutlookComObject($attachment) } } #If no Outlook item is selected, remove any item info Function Clear-OutlookItemInfo { $OutlookState.ItemBCCRecipientList = $null $OutlookState.ItemBodyLength = $null $OutlookState.ItemCCRecipientList = $null $Outlookstate.ItemSender = $null $OutlookState.ItemFolderName = $null $OutlookState.ItemImportance = $null $OutlookState.ItemcreationTime = $null $OutlookState.ItemSize = $null $OutlookState.ItemSubject = $null $OutlookState.ItemToRecipientList = $null $OutlookState.ItemType = $null $OutlookState.ItemEmbeddedAttachmentCount = 0 $OutlookState.ItemVisibleAttachmentCount = 0 $OutlookState.ItemMessageID = $null } #Get info on the explorer window Function Get-OutlookExplorerState { Param( $explorer ) #If it's an explorer and the folder has items, assume user is reading. Note that we can't check which item is selected since this is broken for group folders, it always shows nothign selected write-debug "Checking if the user is reading, composing, or doing nothing in the current explorer" write-debug "Item count of current folder is $($explorer.CurrentFolder.items.count) and number of items selected is $($explorer.CurrentFolder.items.count)" if(($explorer.CurrentFolder.items.count -eq 0) -and ($explorer.selection.Count -eq 0)) { write-debug "User has been determined to not be reading or editing." $OutlookState.UserIsReading = $false $OutlookState.UserIsEditing = $false Clear-OutlookItemInfo } else { #An item is selected so the user is either reading or editing. if($response = $explorer.activeinlineresponse) { write-debug "User has been determined to be editing due to an active inline response." $OutlookState.UserIsEditing = $True $OutlookState.UserIsReading = $false Get-OutlookItemInfo -outlookItem $response Release-OutlookComObject($response) } else { write-debug "User has been determined to be reading since there's no active inline response." $OutlookState.UserIsEditing = $false $OutlookState.UserIsReading = $true #If an item is selected, get its info. If not, it may be a group folder with no item. if($explorer.selection.count -gt 0) { $currentItem = $explorer.selection.item(1) Get-OutlookItemInfo -outlookItem $currentItem Release-OutlookComObject($currentItem) } else { Clear-OutlookItemInfo } } } } #Get info on an inspector window Function Get-OutlookInspectorState { Param( $inspector ) #if an item isn't listed as "sent", it's being edited if($inspector.currentitem.sent) { $OutlookState.UserIsEditing = $false $OutlookState.UserIsReading = $true } else { $OutlookState.UserIsEditing = $True $OutlookState.UserIsReading = $false } $currentItem = $inspector.CurrentItem Get-OutlookItemInfo -outlookItem $currentItem Release-OutlookComObject($currentItem) } ##########Functions to create and update Forms GUI########## #Create Outlook monitor GUI window Function New-OutlookMonitorGUI { Add-Type -AssemblyName System.Windows.Forms [System.Windows.Forms.Application]::EnableVisualStyles() $Form = New-Object system.Windows.Forms.Form $Form.ClientSize = '475,871' $Form.text = "Outlook Monitor" $Form.TopMost = $false $UserIsReading = New-Object system.Windows.Forms.Label $UserIsReading.text = "User is reading" $UserIsReading.AutoSize = $true $UserIsReading.width = 25 $UserIsReading.height = 10 $UserIsReading.location = New-Object System.Drawing.Point(28,61) $UserIsReading.Font = 'Microsoft Sans Serif,16' $UserIsEditing = New-Object system.Windows.Forms.Label $UserIsEditing.text = "User is editing" $UserIsEditing.AutoSize = $true $UserIsEditing.width = 25 $UserIsEditing.height = 10 $UserIsEditing.location = New-Object System.Drawing.Point(28,110) $UserIsEditing.Font = 'Microsoft Sans Serif,16' $ItemInfoDataGridView = New-Object system.Windows.Forms.DataGridView $ItemInfoDataGridView.width = 416 $ItemInfoDataGridView.height = 594 $ItemInfoDataGridView.location = New-Object System.Drawing.Point(25,165) $OutlookInFocus = New-Object system.Windows.Forms.Label $OutlookInFocus.text = "Outlook in focus" $OutlookInFocus.AutoSize = $true $OutlookInFocus.width = 25 $OutlookInFocus.height = 10 $OutlookInFocus.location = New-Object System.Drawing.Point(28,13) $OutlookInFocus.Font = 'Microsoft Sans Serif,16' $LogFileLabel = New-Object system.Windows.Forms.Label $LogFileLabel.text = "Log file path:" $LogFileLabel.AutoSize = $false $LogFileLabel.width = 403 $LogFileLabel.height = 87 $LogFileLabel.location = New-Object System.Drawing.Point(33,770) $LogFileLabel.Font = 'Microsoft Sans Serif,10' $Form.controls.AddRange(@($UserIsReading,$UserIsEditing,$ItemInfoDataGridView,$OutlookInFocus,$LogFileLabel)) $Form.Add_FormClosing({ Close-Form -sender $this -cancelEventArgs $_ }) $Form.MaximizeBox = $false $form.showintaskbar = $false $Form.Add_Shown({New-OutlookMonitor -checkIntervalMilliseconds 500 -sendLogFileIntervalMinutes 720}) $form.windowstate = [System.Windows.Forms.FormWindowState]::Minimized $LogFileLabel.text = "Outlook Monitoring log is stored here: $logFilePath" #Configure datagridview to show item properties $ItemInfoDataGridView.RowHeadersVisible = $false $ItemInfoDataGridView.AutoSizeRowsMode = [System.Windows.Forms.DataGridViewAutoSizeColumnMode]::AllCells $ItemInfoDataGridView.ColumnCount = 2 $ItemInfoDataGridView.ColumnHeadersVisible = $true $ItemInfoDataGridView.ColumnHeadersHeightSizeMode = 2 $ItemInfoDataGridView.Columns[0].Name = "Property" $ItemInfoDataGridView.Columns[0].AutoSizeMode = 6 $ItemInfoDataGridView.Columns[1].DefaultCellStyle.WrapMode = [System.Windows.Forms.DataGridViewTriState]::True $ItemInfoDataGridView.Columns[1].Name = "Value" $ItemInfoDataGridView.Columns[1].AutoSizeMode = 16 if(!$doNotHideConsole) { Hide-PowerShellConsole } Initialize-SystemTrayIcon [void]$Form.ShowDialog() } #Hide powershell console as the GUI runs Function Hide-PowerShellConsole { # Hide PowerShell Console $consolePtr = [Console.Window]::GetConsoleWindow() $null = [Console.Window]::ShowWindow($consolePtr, 0) } #If the form is closed, minimize to system tray and keep running Function Close-Form { Param ( [Object]$sender, [Object]$cancelEventArgs ) #If user clicks the X to minimize, keep it running, if closed from context menu then really close. if($sender -eq $form) { $systemTrayIcon.BalloonTipText = "Outlook Monitor will continue runnning in your system tray, right click to close" $systemTrayIcon.ShowBalloonTip(1000) $form.WindowState = "minimized" $cancelEventArgs.cancel = $true } else { #Dispose of all variables Get-Variable -exclude Runspace | Where-Object { $_.Value -is [System.IDisposable] } | Foreach-Object { try { $_.Value.Dispose() Remove-Variable $_.Name -ErrorAction Stop } catch { } } } } #Create the sytem tray icon Function Initialize-SystemTrayIcon { #Configure the icon $script:systemTrayIcon = New-Object System.Windows.Forms.NotifyIcon $systemTrayIcon.Icon = "$($env:windir)\syswow64\OneDrive.ico" $systemTrayIcon.BalloonTipText = "Outlook Monitor $((Get-InstalledScript "OutlookMonitor").version) is running in your system tray" $systemTrayIcon.Visible = $True $systemTrayIcon.ShowBalloonTip(1000) #enable it pop up the form on click $systemTrayIcon.add_click({$form.WindowState = "Normal"; $form.Activate()}) #Create context menu with option to exit the form $contextMenu = New-Object System.Windows.Forms.ContextMenu $exitMenuItem = New-Object System.Windows.Forms.MenuItem $exitMenuItem.text = "Exit" $exitMenuItem.add_Click({Close-Form -sender $this -cancelEventArgs $_}) [void]$contextMenu.MenuItems.Add($exitMenuItem) $systemTrayIcon.contextMenu = $contextMenu } #Run the process that gets the Outlook state and updates the GUI using a timer Function New-OutlookMonitor { Param( [int]$checkIntervalMilliseconds, [int]$sendLogFileIntervalMinutes ) #Set up timer to check usage state $script:timer = New-Object System.Windows.Forms.Timer $timer.Interval = $checkIntervalMilliseconds $timer.Add_Tick({Get-OutlookUsageState}) $timer.Enabled = $True #Set up stopwatch to send log files on regular intervals $script:sendLogFileTimeSpan = new-timespan -minutes $sendLogFileIntervalMinutes $script:stopwatch = [diagnostics.stopwatch]::StartNew() Write-Debug "Setting up stopwatch to send log files every $sendLogFileIntervalMinutes minutes" } #Update the GUI Function Update-OutlookMonitorGUI { Param( $OutlookState ) if($OutlookState.OutlookInFocus) { $OutlookInFocus.BackColor = "#00FF00" if($OutlookState.UserIsReading) { $UserIsReading.BackColor = "#00FF00" } else { $UserIsReading.BackColor = "#d0021b" } if($OutlookState.UserIsEditing) { $UserIsEditing.BackColor = "#00FF00" } else { $UserIsEditing.BackColor = "#d0021b" } #Fill the datagrid with the item info $ItemInfoDataGridView.rows.clear() if(!$OutlookState.UserIsEditing -and !$OutlookState.UserIsReading) { $ItemInfoDataGridView.rows.Add("No item selected") } else { foreach($property in $OutlookState.psobject.Properties) { $ItemInfoDataGridView.Rows.Add($property.name,$property.value) } } } else { $OutlookInFocus.BackColor = "#d0021b" $UserIsReading.BackColor = "#d0021b" $UserIsEditing.BackColor = "#d0021b" $ItemInfoDataGridView.rows.clear() $ItemInfoDataGridView.rows.Add("No item selected") } $form.Refresh() } ##########Functions concerning manipulating Outlook COM objects######## #Checks if Outlook is running and returns a COM object if so, otherwise null. Function Get-OutlookComObject { if(Get-Process -Name OUTLOOK -ErrorAction SilentlyContinue) { Write-Debug "Retrieving Outlook COM object" new-object -comobject outlook.application } else { $null } } #Release Outlook COM object Function Release-OutlookComObject { Param ( [object]$comObject ) try { [System.Runtime.Interopservices.Marshal]::ReleaseComObject($comObject) | out-null } catch { Write-Debug "Encountered exception releasing COM object $($_.exception.mesage). This can happen if Outlook is closed while the monitor is running." } } #########Functions involving creating and sending the log file############ #set up log file Function Initialize-Log { $folderPath = "$($env:LOCALAPPDATA)\OutlookMonitorLogs" if(!(Test-Path $folderPath)) { $null = New-Item $folderPath -ItemType Directory } "$($folderPath)$($MyInvocation.ScriptName.Replace((Split-Path $MyInvocation.ScriptName),'').TrimStart(''))_$($env:computername)_$($env:username).csv" } #Check if a log file needs to be sent on a regular schedule Function Send-LogFileOnSchedule { Write-Debug "Log file configured to be sent every $($sendLogFileTimeSpan.TotalMinutes) minutes, $($stopwatch.Elapsed.TotalMinutes) minutes have passed since last send" if($stopwatch.elapsed -gt $sendLogFileTimeSpan) { Write-Debug "Sending log file" Send-LogFile -emailAddress 'outlookmonitor@service.microsoft.com' -logFilePath $logFilePath $stopwatch.Restart() Update-OutlookMonitor } else { Write-Debug "Not sending log file" } } #Email the log file to a modern group Function Send-LogFile { Param ( [string]$emailAddress, [string]$logFilePath ) Write-Debug "Compressing log file $logFilePath in preparation to send" $compressedLogFilePath = "$($logFilePath).zip" Compress-Archive -LiteralPath $logFilePath -Update -DestinationPath $compressedLogFilePath if((Get-Item $compressedLogFilePath).Length -gt 104857600) { Write-Debug "Compressed file is > 100MB, log will not be sent" } else { write-debug "Creating message to send log file" try { $message = $outlook.CreateItem([Microsoft.Office.Interop.Outlook.OlItemType]::olMailItem) } catch { Write-Debug "Couldn't create message to send log file due to exception $($_.exception.mesage). This may occur because Outlook is closed." $message = $null } if($message) { $message.To = $emailAddress $message.Subject = "$($MyInvocation.ScriptName.Replace((Split-Path $MyInvocation.ScriptName),'').TrimStart(''))_$($env:computername)_$($env:USERNAME)" $message.Attachments.Add($compressedLogFilePath) Write-Debug "Sending log file to $emailAddress" $null = $message.send() Release-OutlookComObject($message) } else { Write-Debug "Message object does not exist, log file will not be sent. It will try again next time" } } } #########Functions involving installation, uninstallation, and updating############ #Copies script, and creates scheduled task, then runs it Function Install-OutlookMonitor { Write-Debug "Installing Nuget package provider" Install-PackageProvider Nuget -Force Write-Debug "Installing PowerShellGet" Install-Module -Name PowerShellGet -Force #Write-Debug "Copying script to $logFilePath" #Copy-Item -path $MyInvocation.ScriptName -Destination "$($env:LOCALAPPDATA)\OutlookMonitorLogs" Write-Debug "Installing script" Install-script "OutlookMonitor" -Force -Scope CurrentUser $scriptFilePath = "$((Get-InstalledScript "OutlookMonitor").installedlocation)\outlookmonitor.ps1" Write-Debug "Creating scheduled task" Add-OutlookMonitorScheduledTask -filePath $scriptFilePath Write-Debug "Starting scheduled task" Start-ScheduledTask -TaskName "Outlook Monitor" } #Create Outlook Monitor scheduled tasks Function Add-OutlookMonitorScheduledTask { Param( [string]$filePath ) if(Get-ScheduledTask "Outlook Monitor" -ErrorAction SilentlyContinue) { Write-Debug "Outlook Monitor scheduled task exists, not creating it again" } else { Write-Debug "Outlook Monitor scheduled task does not exist, creating it" $argument = "-executionpolicy bypass -file `"$filePath`"" $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $argument $trigger = New-ScheduledTaskTrigger -AtLogOn $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -DontStopOnIdleEnd -MultipleInstances IgnoreNew Register-ScheduledTask -Action $action -Trigger $trigger -TaskName "Outlook Monitor" -Description "Runs Outlook activity monitor" -Settings $settings #-User "System" #-CimSession $CIMSession -RunLevel Highest } } #Uninstall Outlook Monitor Function Uninstall-OutlookMonitor { if(Get-ScheduledTask "Outlook Monitor" -ErrorAction SilentlyContinue) { Write-Debug "Removing Outlook monitor task" Stop-ScheduledTask -taskname "Outlook Monitor" Unregister-ScheduledTask "Outlook Monitor" -Confirm:$false } Write-Debug "Removing Outlook Monitoring Log Folder" Remove-Item "$($env:LOCALAPPDATA)\OutlookMonitorLogs" -Force -Confirm:$false -Recurse uninstall-script "OutlookMonitor" -Force } #Update script file and restart script Function Update-OutlookMonitor { $onlineScriptVersion = (Find-Script "OutlookMonitor").version $currentScriptVersion = (Get-InstalledScript "OutlookMonitor").version Write-Debug "Checking for script update, current version is $currentScriptVersion" if($onlineScriptVersion -gt $currentScriptVersion) { Write-Debug "Script update found, online version is $onlineScriptVersion, installing update" Update-Script -Name "OutlookMonitor" -Force Write-Debug "Restarting with new script version and exiting old one" start-process "PowerShell.exe" -argumentList "-WindowStyle hidden -command Start-ScheduledTask -TaskName `'Outlook Monitor`'" Close-Form } else { write-debug "No script update found, online version is $onlineScriptVersion" } } } Process { ########Script Body######## $logFilePath = Initialize-Log if($install) { Write-Debug "Install parameter used, will create scheduled task" Install-OutlookMonitor } elseif($uninstall) { Write-Debug "Uninstall parameter used, will remove scheduled task" Uninstall-OutlookMonitor } else { #initialize the GUI $script:Outlook = Get-OutlookComObject New-OutlookMonitorGUI } } ##TODO #maybe make sure no other version of the same script is running. <# #Check if a recipient is a DG Function Get-OutlookItemRecipientIsDG { Param( $outlookItemRecipient ) $GAL = $outlook.Session.AddressLists("All Distribution Lists") $outlookItemRecipientAddressEntryFromGAL = $gal.AddressEntries($outlookItemRecipient.name) if($outlookItemRecipient.address -eq $outlookItemRecipientAddressEntryFromGAL.address) { $outlookItemRecipientAddressEntryFromGAL } else { $null } } #> <# #Count number of recipients, 1 for regular user and more for a DG Function Get-OutlookItemRecipientCount { Param( $outlookItemRecipient ) #Doesn't work for all microsoft DG, also doesn't work for Nimrod since there are 2 GAL entreis with same name... #Search for the recipient by name in the "All Distribution lists" address list then check to see if the search result address matches your recipient #If not, it found the closest user name, which means there was no exact match for your recipient and it's in the list of DGs. if($outlookItemRecipientAddressEntryFromGAL = Get-OutlookItemRecipientIsDG -outlookItemRecipient $outlookItemRecipient) { write-host "Recipient name: $($outlookItemRecipient.name) is a DG and PR_display_type is $($outlookitemrecipient.PropertyAccessor.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x39050003"))"#> <#foreach($outlookItemRecipientAddressEntryFromGALL2 in $outlookItemRecipientAddressEntryFromGAL.members) { if($outlookItemRecipientAddressEntryFromGALL3 = Get-OutlookItemRecipientIsDG -outlookItemRecipient $outlookItemRecipientAddressEntryFromGALL2) { write-host "Recipient name: $($outlookItemRecipientAddressEntryFromGALL2.name) is a DG" } else { write-host "Recipient name: $($outlookItemRecipientAddressEntryFromGALL2.name) is not a DG " $memberCount++ } }#> <# $memberCount } else { write-host "Recipient name: $($outlookItemRecipient.name) is a not DG and PR_display_type is $($outlookitemrecipient.PropertyAccessor.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x39050003"))" 1 } } #> |