├── .gitattributes ├── .gitignore ├── .markdownlint.json ├── .vscode └── settings.json ├── InstallMe.ps1 ├── LICENSE ├── MS365HealthReport.psd1 ├── MS365HealthReport.psm1 ├── README.md ├── docs └── images │ ├── api_permissions.png │ ├── consolidated_report.png │ ├── image01.png │ ├── image02.png │ ├── individual_report.png │ ├── ms365_health.png │ ├── ms365_health_max.png │ └── teamsChannelAlertConsolidate.png ├── release_notes.md ├── run-example.ps1 └── source ├── private ├── TeamsConsolidated.json └── style.css └── public ├── Generic.ps1 ├── Get-MS365HealthOverview.ps1 ├── Get-MS365HealthReportLastRunTime.ps1 ├── Get-MS365Messages.ps1 ├── New-ConsolidatedCard.ps1 ├── New-MS365IncidentReport.ps1 ├── ReplaceSmartCharacter.ps1 └── Set-MS365HealthReportLastRunTime.ps1 /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *ignore* 2 | *.cer 3 | run-test.ps1 4 | getToken.ps1 5 | *.zip 6 | *test* 7 | *.csv 8 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD033": false, 3 | "MD013": false, 4 | "MD038": false, 5 | "MD036": false, 6 | "MD051": false 7 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "powershell.codeFormatting.addWhitespaceAroundPipe": true 3 | } -------------------------------------------------------------------------------- /InstallMe.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param ( 3 | [string]$ModulePath 4 | ) 5 | $moduleManifest = Get-ChildItem -Path $PSScriptRoot -Filter *.psd1 6 | $Moduleinfo = Test-ModuleManifest -Path ($moduleManifest.FullName) 7 | 8 | Remove-Module ($Moduleinfo.Name) -ErrorAction SilentlyContinue 9 | 10 | if (!$ModulePath) { 11 | # Get all PSModulePath 12 | $paths = ($env:PSModulePath -split ";") 13 | 14 | do { 15 | Clear-Host 16 | # Display selection menu 17 | Say "====== Module Install Location ======" 18 | Say "" 19 | $i = 1 20 | $paths | ForEach-Object { 21 | Say "$($i): $_" 22 | $i = $i + 1 23 | } 24 | Say "Q: QUIT" 25 | Say "" 26 | # AS for input 27 | $userInput = Read-Host "Select the installation path" 28 | } 29 | until ($userInput -eq 'Q' -or ($userInput -lt ($paths.count + 1) -and $userInput -gt 0)) 30 | 31 | if ($userInput -eq 'Q') { 32 | Say "" 33 | Say "QUIT" 34 | Say "" 35 | return $null 36 | } 37 | $ModulePath = $paths[($userInput - 1)] 38 | } 39 | $ModulePath = $ModulePath + "\$($Moduleinfo.Name.ToString())\$($Moduleinfo.Version.ToString())" 40 | 41 | if (!(Test-Path $ModulePath)) { 42 | New-Item -Path $ModulePath -ItemType Directory | Out-Null 43 | } 44 | 45 | try { 46 | Copy-Item -Path $PSScriptRoot\* -Include *.psd1, *.psm1 -Destination $ModulePath -Force -Confirm:$false -ErrorAction Stop 47 | Copy-Item -Path $PSScriptRoot\source -recurse -Destination $ModulePath -Force -Confirm:$false -ErrorAction Stop 48 | Say "" 49 | Say "Success. Installed to $ModulePath" 50 | Say "" 51 | #Import-Module ExCmdReport 52 | Get-ChildItem -Recurse $ModulePath | Unblock-File -Confirm:$false 53 | } 54 | catch { 55 | Say "" 56 | Say "Failed" 57 | Say $_.Exception.Message 58 | Say "" 59 | } 60 | 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 June Castillote 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MS365HealthReport.psd1: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junecastillote/MS365HealthReport/1d1cee28e95c1cc9d7ff6acf138e2ec75dea4793/MS365HealthReport.psd1 -------------------------------------------------------------------------------- /MS365HealthReport.psm1: -------------------------------------------------------------------------------- 1 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 2 | $Path = [System.IO.Path]::Combine($PSScriptRoot, 'source') 3 | Get-Childitem $Path -Filter *.ps1 -Recurse | Foreach-Object { 4 | . $_.Fullname 5 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MS365HealthReport 2 | 3 | [![GitHub issues](https://img.shields.io/github/issues/junecastillote/MS365HealthReport)](https://github.com/junecastillote/MS365HealthReport/issues) 4 | [![GitHub forks](https://img.shields.io/github/forks/junecastillote/MS365HealthReport)](https://github.com/junecastillote/MS365HealthReport/network) 5 | [![GitHub issues](https://img.shields.io/github/stars/junecastillote/MS365HealthReport)](https://github.com/junecastillote/MS365HealthReport/issues) 6 | [![GitHub license](https://img.shields.io/github/license/junecastillote/MS365HealthReport)](https://github.com/junecastillote/MS365HealthReport/blob/main/LICENSE) 7 | 8 | - [Overview](#overview) 9 | - [Release Notes](#release-notes) 10 | - [Requirements](#requirements) 11 | - [How to Get the Module](#how-to-get-the-module) 12 | - [~~OPTION 1: Installing from PowerShell Gallery~~](#option-1-installing-from-powershell-gallery) 13 | - [OPTION 2: Download from GitHub](#option-2-download-from-github) 14 | - [Syntax](#syntax) 15 | - [Parameter Set 1: Authenticate using Client Secret](#parameter-set-1-authenticate-using-client-secret) 16 | - [Parameter Set 2: Authenticate using Client Certificate](#parameter-set-2-authenticate-using-client-certificate) 17 | - [Parameter Set 3: Authenticate using Client Certificate Thumbprint](#parameter-set-3-authenticate-using-client-certificate-thumbprint) 18 | - [Parameters](#parameters) 19 | - [Usage Examples](#usage-examples) 20 | - [Example 1: Getting the Open Issues Updated Within the Last 10 Days](#example-1-getting-the-open-issues-updated-within-the-last-10-days) 21 | - [Example 2: Getting All Exchange and SharePoint Issues Updated Since the Last Run](#example-2-getting-all-exchange-and-sharepoint-issues-updated-since-the-last-run) 22 | - [Example 3: Send Notification to a Teams Channel](#example-3-send-notification-to-a-teams-channel) 23 | - [Screenshots](#screenshots) 24 | 25 | ## Overview 26 | 27 | Retrieve the Microsoft 365 Service Health status and send the email report using Microsoft Graph API. 28 | 29 | > *Important: As of December 17, 2021, Microsoft has deprecated the **Office 365 Service Communications API**, which caused the previous version of this module (v1.4.2) to stop working. This new version now uses only Microsoft Graph API. Please refer to the new set of required API permissions in the [Requirements](#requirements) section.* 30 | 31 | ![Example Incident Summary](docs/images/consolidated_report.png) 32 | 33 | ![Example Incident Alert](docs/images/individual_report.png) 34 | 35 | ![Teams Channel Notification](docs/images/teamsChannelAlertConsolidate.png) 36 | 37 | ## Release Notes 38 | 39 | Go to [Release Notes](release_notes.md). 40 | 41 | ## Requirements 42 | 43 | - A registered Azure AD (OAuth) App with the following settings: 44 | - Application Name: *MS365HealthReport* 45 | - API: *Microsoft Graph* 46 | - Permission Type: *Application* 47 | - Permission(s): *`Mail.Send`, `ServiceHealth.Read.All`, `ServiceMessage.Read.All`, `Teamwork.Migrate.All`* 48 | 49 | ![Api Permissions](docs/images/api_permissions.png)
API Permissions 50 | 51 | - Windows PowerShell 5.1 or PowerShell 7.1+ on a Windows or Linux host (not tested with PowerShell on macOS). 52 | 53 | - The [*MSAL.PS PowerShell Module*](https://www.powershellgallery.com/packages/MSAL.PS/) must be installed on the computer where you will be running this script. The minimum version required is 4.16.0.4. 54 | 55 | - A valid mailbox used for sending the report. A shared mailbox (no license) is recommended. 56 | 57 | ## How to Get the Module 58 | 59 | > ***Note: The PowerShell Gallery module is not being updated regularly. Go to [OPTION 2: Installing from the Source](#option-2-installing-from-the-source) instead.*** 60 | 61 | ### ~~OPTION 1: Installing from PowerShell Gallery~~ 62 | 63 | ~~The most convenient way to get this module is by installing from PowerShell Gallery.~~ 64 | 65 | ```PowerShell 66 | Install-Module MS365HealthReport 67 | ``` 68 | 69 | ~~Or if you're deploying to Azure Automation, you can directly [import from PowerShell gallery](https://docs.microsoft.com/en-us/azure/automation/shared-resources/modules#import-modules-from-the-powershell-gallery).~~ 70 | 71 | ### OPTION 2: Download from GitHub 72 | 73 | - [Download](https://github.com/junecastillote/MS365HealthReport/archive/refs/heads/main.zip) or [Clone](https://github.com/junecastillote/MS365HealthReport.git) the code. 74 | - Extract the downloaded zip and/or go to the code folder. 75 | - Run the `InstallMe.ps1` script. 76 | 77 | ## Syntax 78 | 79 | ### Parameter Set 1: Authenticate using Client Secret 80 | 81 | ```PowerShell 82 | New-MS365IncidentReport 83 | -ClientID 84 | -ClientSecret 85 | -TenantID 86 | [-OrganizationName ] 87 | [-StartFromLastRun] 88 | [-LastUpdatedTime ] 89 | [-Workload ] 90 | [-Status ] 91 | [-SendEmail] 92 | [-From ] 93 | [-To ] 94 | [-CC ] 95 | [-Bcc ] 96 | [-WriteReportToDisk ] 97 | [-Consolidate ] 98 | [] 99 | ``` 100 | 101 | ### Parameter Set 2: Authenticate using Client Certificate 102 | 103 | ```PowerShell 104 | New-MS365IncidentReport 105 | -ClientID 106 | -ClientCertificate 107 | -TenantID 108 | [-OrganizationName ] 109 | [-StartFromLastRun] 110 | [-LastUpdatedTime ] 111 | [-Workload ] 112 | [-Status ] 113 | [-SendEmail] 114 | [-From ] 115 | [-To ] 116 | [-CC ] 117 | [-Bcc ] 118 | [-WriteReportToDisk ] 119 | [-Consolidate ] 120 | [] 121 | ``` 122 | 123 | ### Parameter Set 3: Authenticate using Client Certificate Thumbprint 124 | 125 | ```PowerShell 126 | New-MS365IncidentReport 127 | -ClientID 128 | -ClientCertificateThumbprint 129 | -TenantID 130 | [-OrganizationName ] 131 | [-StartFromLastRun] 132 | [-LastUpdatedTime ] 133 | [-Workload ] 134 | [-Status ] 135 | [-SendEmail] 136 | [-From ] 137 | [-To ] 138 | [-CC ] 139 | [-Bcc ] 140 | [-WriteReportToDisk ] 141 | [-Consolidate ] 142 | [] 143 | ``` 144 | 145 | ## Parameters 146 | 147 | | Parameter | Notes | 148 | | ----------------------------- | ------------------------------------------------------------ | 149 | | `ClientID` | This is the Client ID / Application ID of the registered Azure AD App. | 150 | | `ClientSecret` | The client secret key associated with the registered Azure AD App. | 151 | | `ClientCertificate` | If you uploaded a client certificate to the registered Azure AD App, you can use it instead of the client secret to authenticate.

To use this, you need to get the *X509Certificate2* object fromt certificate store.

eg.
`$certificate = Get-Item Cert:\CurrentUser\My\`
| 152 | | `ClientCertificateThumbprint` | If you uploaded a client certificate to the registered Azure AD App, you can use it instead of the client secret to authenticate.

To use this, you only need to specify the certificate thumbprint. The script will automatically get the certificate from the personal certificate store.
| 153 | | `OrganizationName` | The organization name you want to appear in the alerts/reports. This is not retrieved from Azure automatically to keep minimum API permissions. | 154 | | `StartFromLastRun` | Using this, the module gets the last run time from the history file. Then, only the incidents that were updated after the retrieved timestamp is reported. This is not recommended to use if you're running the module in Azure Automation.

History file - *`%userprofile%\\runHistory.csv`* 155 | | `LastUpdatedTime` | Use this if you want to limit the period of the report to include only the incidents that were updated after this time. This parameter overrides the `StartFromLastRun` switch. | 156 | | `Workload` | By default, all workloads are reported. If you want to limit the report to specific workloads only, specify the workload names here.

NOTE: Workload names are case-sensitive. If you want to get all the list of exact workload names that are available in your tenant, use the `Get-MS365HealthOverview -Token ` command included in this module, or view them using the Service Health dashboard on the Admin Centre. | 157 | | `Status` | New in v2. Filters the query result based on status. Valid values are:

`Ongoing` - for current open issues only.
`Closed` - for returning only resolved issues.

If you do not use this parameter, all (Ongoing and Closed) will be returned. | 158 | | `SendEmail` | Use this switch parameter to indicate that you want to send the report by email. | 159 | | `From` | This is the sender address. The mailbox must be valid. You can use a Shared Mailbox that has no license for this. Also, this is required if you enabled `-SendEmail` | 160 | | `To` | This is the To recipient addresses. Required if you used `-SendEmail`. | 161 | | `Cc` | This is the CC recipient addresses. This is optional. | 162 | | `Bcc` | This is the CC recipient addresses. This is optional. | 163 | | `WriteReportToDisk` | By default, the reports are saved to disk. If you don't want to save to disk, or if you're running this in Azure Automation, you can set this parameter to `$false`

The default output folder is `$($env:USERPROFILE)\$($ModuleInfo.Name)\$($TenantID)`. | 164 | | `Consolidate` | Boolean parameter. If set to `$true` (default), the alerts are consolidated and sent in one email. If set to `$false`, each alert is sent separately. | 165 | | `SendTeams` | Boolean parameter. If set `$true`, the notification will be sent to the Teams Webhook URL. The default value is `$false`
As of version `2.1.5`, the only Teams notification option is consolidated (default). | 166 | | `TeamsWebHookURL` | The array of Teams Webhook URL to send the notification.
Refer to [Create Incoming Webhooks](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook). | 167 | 168 | ## Usage Examples 169 | 170 | ### Example 1: Getting the Open Issues Updated Within the Last 10 Days 171 | 172 | This example gets all open issues updated within the last 10 days. 173 | 174 | ```PowerShell 175 | Import-Module MS365HealthReport 176 | $reportSplat = @{ 177 | OrganizationName = 'Organization Name Here' 178 | ClientID = 'Client ID here' 179 | ClientSecret = 'Client Secret here' 180 | TenantID = 'Tenant ID here' 181 | SendEmail = $true 182 | From = 'sender@domain.com' 183 | To = @('to@domain.com') 184 | LastUpdatedTime = (Get-Date).AddDays(-10) 185 | Status = 'Ongoing' 186 | } 187 | 188 | New-MS365IncidentReport @reportSplat 189 | ``` 190 | 191 | ### Example 2: Getting All Exchange and SharePoint Issues Updated Since the Last Run 192 | 193 | This example gets the last run time from the history file and only return the updates after that time. 194 | 195 | Each alert is sent separately because the `Consolidate` parameter is set to `$false`. 196 | 197 | ```PowerShell 198 | Import-Module MS365HealthReport 199 | 200 | $reportSplat = @{ 201 | OrganizationName = 'Organization Name Here' 202 | ClientID = 'Client ID here' 203 | ClientSecret = 'Client Secret here' 204 | TenantID = 'Tenant ID here' 205 | SendEmail = $true 206 | From = 'sender@domain.com' 207 | To = @('to@domain.com') 208 | StartFromLastRun = $true 209 | Workload = @('Exchange Online','SharePoint Online') 210 | Consolidate = $false 211 | } 212 | 213 | New-MS365IncidentReport @reportSplat 214 | ``` 215 | 216 | ### Example 3: Send Notification to a Teams Channel 217 | 218 | ```PowerShell 219 | Import-Module MS365HealthReport 220 | 221 | $reportSplat = @{ 222 | OrganizationName = 'Organization Name Here' 223 | ClientID = 'Client ID here' 224 | ClientSecret = 'Client Secret here' 225 | TenantID = 'Tenant ID here' 226 | StartFromLastRun = $true 227 | Workload = @('Exchange Online','SharePoint Online') 228 | SendTeams = $true 229 | TeamsWebHookURL = @('https://contoso.webhook.office.com/webhookb2/340e8862...') 230 | } 231 | 232 | New-MS365IncidentReport @reportSplat 233 | ``` 234 | 235 | ## Screenshots 236 | 237 | ![Sample run](docs/images/image01.png)
Sample run 238 | -------------------------------------------------------------------------------- /docs/images/api_permissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junecastillote/MS365HealthReport/1d1cee28e95c1cc9d7ff6acf138e2ec75dea4793/docs/images/api_permissions.png -------------------------------------------------------------------------------- /docs/images/consolidated_report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junecastillote/MS365HealthReport/1d1cee28e95c1cc9d7ff6acf138e2ec75dea4793/docs/images/consolidated_report.png -------------------------------------------------------------------------------- /docs/images/image01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junecastillote/MS365HealthReport/1d1cee28e95c1cc9d7ff6acf138e2ec75dea4793/docs/images/image01.png -------------------------------------------------------------------------------- /docs/images/image02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junecastillote/MS365HealthReport/1d1cee28e95c1cc9d7ff6acf138e2ec75dea4793/docs/images/image02.png -------------------------------------------------------------------------------- /docs/images/individual_report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junecastillote/MS365HealthReport/1d1cee28e95c1cc9d7ff6acf138e2ec75dea4793/docs/images/individual_report.png -------------------------------------------------------------------------------- /docs/images/ms365_health.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junecastillote/MS365HealthReport/1d1cee28e95c1cc9d7ff6acf138e2ec75dea4793/docs/images/ms365_health.png -------------------------------------------------------------------------------- /docs/images/ms365_health_max.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junecastillote/MS365HealthReport/1d1cee28e95c1cc9d7ff6acf138e2ec75dea4793/docs/images/ms365_health_max.png -------------------------------------------------------------------------------- /docs/images/teamsChannelAlertConsolidate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junecastillote/MS365HealthReport/1d1cee28e95c1cc9d7ff6acf138e2ec75dea4793/docs/images/teamsChannelAlertConsolidate.png -------------------------------------------------------------------------------- /release_notes.md: -------------------------------------------------------------------------------- 1 | # Release Notes 2 | 3 | ## v2.1.7 (2024-07-24) 4 | 5 | - Reworked error handling in `New-MS365IncidentReport` for Teams notification in relation to [Retirement of Office 365 connectors within Microsoft Teams](https://devblogs.microsoft.com/microsoft365dev/retirement-of-office-365-connectors-within-microsoft-teams/) 6 | 7 | ## v2.1.6 (2023-02-22) 8 | 9 | - Fixed the Teams notification error that happens when the JSON payload shows {} for null in Windows PowerShell. This error does not happen in PowerShell 7. 10 | - It appears that ConvertTo-Json in Windows PowerShell outputs empty values as `{}`, while PowerShell 7 outputs empty as `null` - which is the correct one for Teams adaptive cards. 11 | - Added replacement for `\u0027` (unicode apostrophe) to `'`. 12 | 13 | ## v2.1.5 14 | 15 | - Added `NoResult` runtime status to the Run History File when there are no new or updated events. 16 | - Added functionality to send consolidated notifications to Teams Webhook. 17 | - The Teams-related parameters are: 18 | - `-SendTeams `. Default is `$false`. 19 | - `-TeamsWebHookURL `. Accepts an array of Teams incoming webhook URLs. 20 | 21 | ## v2.1.4 22 | 23 | - Fixed the bad request (400) error when there's no Status, Workload, or LastUpdatedTime filter specified. 24 | 25 | ## v2.1.3 26 | 27 | - If the `ClientCertificateThumbprint` is specified, the script will now look for the matching certificate in `Cert:\CurrentUser\My` and `Cert:\LocalMachine\My`. 28 | - Reminder: `ClientCertificateThumbprint` is only applicable on Windows systems. 29 | 30 | ## v2.1.2 31 | 32 | - Fixed error in output directory handling whenever the `$ENV:HOME` or `$ENV:HOMEPATH` does not exist. 33 | 34 | ## v2.1.1 35 | 36 | - Minor code change to properly display the "Status" values. 37 | - Example: `extendedRecovery` = `Extended Recovery` 38 | - Example: `serviceDegradation` = `Service Degradation`. 39 | - Added unicode space character replacement code. 40 | - Changed `LastRunTime` location from the registry to a CSV file on the user's home folder. 41 | - On Windows systems - *`Env:\HOMEPATH\MS365HealthReport\\runHistory.csv`* 42 | - On non-Windows systems - *`Env:\HOME\MS365HealthReport\\runHistory.csv`* 43 | - Now compatible with PowerShell on Linux. 44 | 45 | ## v2.1 46 | 47 | - Patched the smart quote replace code. Strings with smart single and double quotes are causing the email sending to fail. 48 | 49 | ## v2.0 50 | 51 | - Removed Office 365 Service Communications API and replaced with Microsoft Graph API to read the service health events. 52 | - Removed the `JWTDetails` module as a requirement. 53 | - Added `-Status` parameter to filter query results based on status (`Ongoing` or `Closed`). This parameter is optional and if not used, all issues will be retrieved. 54 | - Added `Get-MS365HealthOverview` which you can use to retrieve the health overview summary only. 55 | - Removed `Get-MS365CurrentStatus` as it is no longer applicable. Use `Get-MS365HealthOverview` instead. 56 | 57 | ## v1.4.2 58 | 59 | - Fixed error in reading the last run timestamp from the registry. 60 | 61 | ## v1.4.1 62 | 63 | - Add "Classification" column to summary. 64 | - Add On-page anchor links in summary. 65 | 66 | ## v1.4 67 | 68 | - Add `-Consolidate` parameter (boolean) to consolidate reports in one email. 69 | 70 | ## v1.3 71 | 72 | - Code cleanup. 73 | - Fixed some JSON related errors. 74 | 75 | ## v1.2 76 | 77 | - Add code to force TLS 1.2 connection [Issue #2](https://github.com/junecastillote/MS365HealthReport/issues/1) 78 | 79 | ## v1.1 80 | 81 | - Added logic to replace smart quotes in messages [Issue #1](https://github.com/junecastillote/MS365HealthReport/issues/1) 82 | -------------------------------------------------------------------------------- /run-example.ps1: -------------------------------------------------------------------------------- 1 | $reportSplat = @{ 2 | OrganizationName = 'Organization Name Here' 3 | ClientID = 'Client ID here' 4 | ClientSecret = 'Client Secret here' 5 | # ClientCertificate = get-item -path "Cert:\currentuser\my\$thumbprint" 6 | TenantID = 'Tenant ID here' 7 | SendEmail = $true 8 | From = 'sender@domain.com' 9 | To = @('to@domain.com') 10 | LastUpdatedTime = (Get-Date).AddDays(-10) 11 | # Workload = @('Exchange Online','SharePoint Online') 12 | Status = 'Ongoing' 13 | } 14 | 15 | New-MS365IncidentReport @reportSplat -------------------------------------------------------------------------------- /source/private/TeamsConsolidated.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "message", 3 | "attachments": [ 4 | { 5 | "contentType": "application/vnd.microsoft.card.adaptive", 6 | "contentUrl": null, 7 | "content": { 8 | "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", 9 | "type": "AdaptiveCard", 10 | "msTeams": { 11 | "width": "full" 12 | }, 13 | "version": "1.4", 14 | "body": [ 15 | { 16 | "type": "ColumnSet", 17 | "style": "accent", 18 | "bleed": true, 19 | "separator": true, 20 | "columns": [ 21 | { 22 | "type": "Column", 23 | "width": "auto", 24 | "items": [ 25 | { 26 | "type": "Image", 27 | "url": "https://raw.githubusercontent.com/junecastillote/MS365HealthReport/main/docs/images/ms365_health.png", 28 | "size": "Medium", 29 | "altText": "MS365HealthReport", 30 | "style": "Person" 31 | } 32 | ] 33 | }, 34 | { 35 | "type": "Column", 36 | "width": "stretch", 37 | "separator": true, 38 | "items": [ 39 | { 40 | "type": "TextBlock", 41 | "text": "Hello! Here are the new and updated service health alerts in your Microsoft 365 tenant.", 42 | "wrap": true 43 | } 44 | ] 45 | } 46 | ] 47 | } 48 | ] 49 | } 50 | } 51 | ] 52 | } -------------------------------------------------------------------------------- /source/private/style.css: -------------------------------------------------------------------------------- 1 | #section { 2 | font-family: "Segoe UI"; 3 | width: fit-content; 4 | border-collapse: collapse; 5 | } 6 | 7 | #section th { 8 | font-size: 24px; 9 | text-align: left; 10 | padding-top: 5px; 11 | padding-bottom: 4px; 12 | background-color: #fff; 13 | color: #000; 14 | } 15 | 16 | #data { 17 | font-family: "Segoe UI"; 18 | width: fit-content; 19 | border-collapse: collapse; 20 | font-size: 15px; 21 | padding-top: 5px; 22 | padding-bottom: 4px; 23 | text-align: left; 24 | vertical-align: top; 25 | } 26 | 27 | #data td, #data th { 28 | border: 1px solid #DDD; 29 | padding: 3px 7px 2px 7px; 30 | } 31 | 32 | #data th { 33 | background-color: #00B388; 34 | color: #fff; 35 | } 36 | -------------------------------------------------------------------------------- /source/public/Generic.ps1: -------------------------------------------------------------------------------- 1 | Function SayError { 2 | param( 3 | $Text 4 | ) 5 | $originalForegroundColor = $Host.UI.RawUI.ForegroundColor 6 | $Host.UI.RawUI.ForegroundColor = 'Red' 7 | "$(Get-Date -Format 'dd-MMM-yyyy HH:mm:ss') : [ERROR] - $Text" | Out-Default 8 | $Host.UI.RawUI.ForegroundColor = $originalForegroundColor 9 | } 10 | 11 | Function SayInfo { 12 | param( 13 | $Text 14 | ) 15 | $originalForegroundColor = $Host.UI.RawUI.ForegroundColor 16 | $Host.UI.RawUI.ForegroundColor = 'Green' 17 | "$(Get-Date -Format 'dd-MMM-yyyy HH:mm:ss') : [INFO] - $Text" | Out-Default 18 | $Host.UI.RawUI.ForegroundColor = $originalForegroundColor 19 | } 20 | 21 | Function SayWarning { 22 | param( 23 | $Text 24 | ) 25 | $originalForegroundColor = $Host.UI.RawUI.ForegroundColor 26 | $Host.UI.RawUI.ForegroundColor = 'Red' 27 | "$(Get-Date -Format 'dd-MMM-yyyy HH:mm:ss') : [WARNING] - $Text" | Out-Default 28 | $Host.UI.RawUI.ForegroundColor = $originalForegroundColor 29 | } 30 | 31 | Function Say { 32 | param( 33 | $Text 34 | ) 35 | $originalForegroundColor = $Host.UI.RawUI.ForegroundColor 36 | $Host.UI.RawUI.ForegroundColor = 'Cyan' 37 | $Text | Out-Default 38 | $Host.UI.RawUI.ForegroundColor = $originalForegroundColor 39 | } 40 | 41 | Function LogEnd { 42 | $txnLog = "" 43 | Do { 44 | try { 45 | Stop-Transcript | Out-Null 46 | } 47 | catch [System.InvalidOperationException] { 48 | $txnLog = "stopped" 49 | } 50 | } While ($txnLog -ne "stopped") 51 | } 52 | 53 | Function LogStart { 54 | param ( 55 | [Parameter(Mandatory = $true)] 56 | [string]$logPath 57 | ) 58 | LogEnd 59 | Start-Transcript $logPath -Force | Out-Null 60 | } 61 | 62 | Function isWindows { 63 | param() 64 | if ([System.Environment]::OSVersion.Platform -eq 'Win32NT') { 65 | return $true 66 | } 67 | else { 68 | return $false 69 | } 70 | } 71 | 72 | Function IsPsCore { 73 | if ($PSVersionTable.PSEdition -eq 'Core') { 74 | return $true 75 | } 76 | else { 77 | return $false 78 | } 79 | } -------------------------------------------------------------------------------- /source/public/Get-MS365HealthOverview.ps1: -------------------------------------------------------------------------------- 1 | Function Get-MS365HealthOverview { 2 | [cmdletbinding(DefaultParameterSetName = 'All')] 3 | param ( 4 | [Parameter(Mandatory)] 5 | [string] 6 | $Token, 7 | 8 | # List of services to retrieve 9 | [Parameter()] 10 | [string[]]$Workload 11 | ) 12 | $headers = @{"Authorization" = "Bearer $($Token)" } 13 | $uri = "https://graph.microsoft.com/beta/admin/serviceAnnouncement/healthOverviews" 14 | 15 | if ($Workload) { 16 | $uri = "$uri`?`$filter=service eq '$($Workload[0])'" 17 | for ($i = 1; $i -lt $workload.Count; $i++) { 18 | $uri = "$uri or service eq '$($Workload[$i])'" 19 | } 20 | } 21 | 22 | SayInfo "Query = $uri" 23 | 24 | try { 25 | $response = @((Invoke-RestMethod -Uri $uri -Method GET -Headers $headers -ErrorAction STOP).value) 26 | } 27 | catch { 28 | SayError "$($_.Exception.Message)" 29 | return $null 30 | } 31 | 32 | return $($response | Sort-Object service) 33 | 34 | } -------------------------------------------------------------------------------- /source/public/Get-MS365HealthReportLastRunTime.ps1: -------------------------------------------------------------------------------- 1 | Function Get-MS365HealthReportLastRunTime { 2 | [CmdletBinding()] 3 | param ( 4 | [parameter(Mandatory)] 5 | [ValidateNotNullOrEmpty()] 6 | [string]$TenantID 7 | ) 8 | $now = Get-Date 9 | $RegPath = "HKCU:\Software\MS365HealthReport\$TenantID" 10 | 11 | try { 12 | $value = Get-ItemPropertyValue -Path $RegPath -Name "(default)" -ErrorAction Stop 13 | return $(Get-Date $value) 14 | } 15 | catch { 16 | Set-MS365HealthReportLastRunTime -TenantID $TenantID -LastRunTime $now 17 | return $now 18 | } 19 | } -------------------------------------------------------------------------------- /source/public/Get-MS365Messages.ps1: -------------------------------------------------------------------------------- 1 | # Function to get Office 365 Messages 2 | Function Get-MS365Messages { 3 | [cmdletbinding()] 4 | param( 5 | [parameter(Mandatory, Position = 0)] 6 | [ValidateNotNullOrEmpty()] 7 | [string] 8 | $Token, 9 | 10 | [parameter()] 11 | [ValidateNotNullOrEmpty()] 12 | [string[]]$Workload, 13 | 14 | [parameter()] 15 | [datetime]$LastUpdatedTime, 16 | 17 | [parameter()] 18 | [ValidateSet('Ongoing', 'Resolved')] 19 | [string]$Status 20 | ) 21 | 22 | $header = @{'Authorization' = "Bearer $($Token)" } 23 | # $uri = "https://manage.office.com/api/v1.0/$($tenantID)/ServiceComms/Messages" 24 | $uri = 'https://graph.microsoft.com/beta/admin/serviceAnnouncement/issues' 25 | 26 | # If any (Workload, LastUpdatedTime, Status) 27 | if ($Workload -or $LastUpdatedTime -or $Status) { 28 | $uri = "$($uri)?filter=" 29 | } 30 | 31 | # If event status is not yet resolved 32 | if ($Status -eq 'Ongoing') { 33 | $uri = "$($uri)isResolved ne true" 34 | } 35 | 36 | # If event status is resolved 37 | if ($Status -eq 'Resolved') { 38 | $uri = "$($uri)isResolved eq true" 39 | } 40 | 41 | # If LastUpdatedTime is specified 42 | if ($LastUpdatedTime) { 43 | $lastModifiedDateTime = Get-Date ($LastUpdatedTime.ToUniversalTime()) -UFormat "%Y-%m-%dT%RZ" 44 | if ($uri.Split('=')[1] -ne '') { 45 | $uri = "$uri and lastModifiedDateTime ge $lastModifiedDateTime" 46 | } 47 | else { 48 | $uri = "$($uri)lastModifiedDateTime ge $lastModifiedDateTime" 49 | } 50 | } 51 | 52 | # If -Workload [workload names[]] 53 | if ($Workload) { 54 | if ($uri.Split('=')[1] -ne '') { 55 | $uri = "$($uri) and (service eq '$($Workload[0])'" 56 | } 57 | else { 58 | $uri = "$uri(service eq '$($Workload[0])'" 59 | } 60 | # $uri = "$uri and (service eq '$($Workload[0])'" 61 | for ($i = 1; $i -lt $workload.Count; $i++) { 62 | $uri = "$uri or service eq '$($Workload[$i])'" 63 | } 64 | $uri = "$uri)" 65 | } 66 | 67 | SayInfo "Query = $uri" 68 | 69 | try { 70 | $result = @((Invoke-RestMethod -Uri $uri -Headers $header -Method Get -ErrorAction Stop).value) 71 | return $result 72 | } 73 | catch { 74 | SayError "$($_.Exception.Message)" 75 | return $null 76 | } 77 | } -------------------------------------------------------------------------------- /source/public/New-ConsolidatedCard.ps1: -------------------------------------------------------------------------------- 1 | # This function creates a consolidated Teams report 2 | # using adaptive cards 1.4. 3 | Function New-ConsolidatedCard { 4 | [CmdletBinding()] 5 | param ( 6 | [Parameter(Mandatory)] 7 | $InputObject 8 | ) 9 | 10 | $moduleInfo = Get-Module $($MyInvocation.MyCommand.ModuleName) 11 | 12 | Function New-FactItem { 13 | [CmdletBinding()] 14 | param ( 15 | [Parameter(Mandatory)] 16 | $InputObject 17 | ) 18 | 19 | $factHeader = [pscustomobject][ordered]@{ 20 | type = "Container" 21 | style = "emphasis" 22 | bleed = $true 23 | items = @( 24 | $([pscustomobject][ordered]@{ 25 | type = 'TextBlock' 26 | wrap = $true 27 | separator = $true 28 | weight = 'Bolder' 29 | text = "$($InputObject.id) | $($InputObject.Service) | $($InputObject.Title)" 30 | } ) 31 | ) 32 | } 33 | 34 | $factSet = [pscustomobject][ordered]@{ 35 | type = 'FactSet' 36 | separator = $true 37 | facts = @( 38 | $([pscustomobject][ordered]@{Title = 'Impact'; Value = $($InputObject.impactDescription) } ), 39 | $([pscustomobject][ordered]@{Title = 'Type'; Value = ($InputObject.Classification.substring(0, 1).toupper() + $InputObject.Classification.substring(1)) } ), 40 | $([pscustomobject][ordered]@{Title = 'Status'; Value = ($InputObject.Status.substring(0, 1).toupper() + $InputObject.Status.substring(1) -creplace '[^\p{Ll}\s]', ' $&').Trim(); } ), 41 | $([pscustomobject][ordered]@{Title = 'Update'; Value = ("{0:MMMM dd, yyyy hh:mm tt}" -f [datetime]$InputObject.lastModifiedDateTime) }), 42 | $([pscustomobject][ordered]@{Title = 'Start'; Value = ("{0:MMMM dd, yyyy hh:mm tt}" -f [datetime]$InputObject.startDateTime) }), 43 | $([pscustomobject][ordered]@{Title = 'End'; Value = $( 44 | if ($InputObject.endDateTime) { 45 | ("{0:MMMM dd, yyyy hh:mm tt}" -f [datetime]$InputObject.startDateTime) 46 | } 47 | else { 48 | $null 49 | } 50 | ) 51 | } 52 | ) 53 | ) 54 | } 55 | return @($factHeader, $factSet) 56 | } 57 | 58 | $teamsAdaptiveCard = (Get-Content (($moduleInfo.ModuleBase.ToString()) + '\source\private\TeamsConsolidated.json') -Raw | ConvertFrom-Json) 59 | foreach ($item in $InputObject) { 60 | $teamsAdaptiveCard.attachments[0].content.body += (New-FactItem -InputObject $item) 61 | } 62 | # $teamsAdaptiveCard = (($teamsAdaptiveCard | ConvertTo-Json -Depth 50)) 63 | return $teamsAdaptiveCard 64 | } -------------------------------------------------------------------------------- /source/public/New-MS365IncidentReport.ps1: -------------------------------------------------------------------------------- 1 | Function New-MS365IncidentReport { 2 | [cmdletbinding(DefaultParameterSetName = 'Client Secret')] 3 | param ( 4 | [parameter()] 5 | [string] 6 | $OrganizationName, 7 | 8 | [parameter(Mandatory, ParameterSetName = 'Client Certificate')] 9 | [parameter(Mandatory, ParameterSetName = 'Certificate Thumbprint')] 10 | [parameter(Mandatory, ParameterSetName = 'Client Secret')] 11 | [guid] 12 | $ClientID, 13 | 14 | [parameter(Mandatory, ParameterSetName = 'Client Secret')] 15 | [string] 16 | $ClientSecret, 17 | 18 | [parameter(Mandatory, ParameterSetName = 'Client Certificate')] 19 | [System.Security.Cryptography.X509Certificates.X509Certificate2] 20 | $ClientCertificate, 21 | 22 | [parameter(Mandatory, ParameterSetName = 'Certificate Thumbprint')] 23 | [string] 24 | $ClientCertificateThumbprint, 25 | 26 | [parameter(Mandatory, ParameterSetName = 'Client Certificate')] 27 | [parameter(Mandatory, ParameterSetName = 'Certificate Thumbprint')] 28 | [parameter(Mandatory, ParameterSetName = 'Client Secret')] 29 | [string] 30 | $TenantID, 31 | 32 | [Parameter()] 33 | [switch] 34 | $StartFromLastRun, 35 | 36 | [Parameter()] 37 | [datetime] 38 | $LastUpdatedTime, 39 | 40 | [Parameter()] 41 | [string[]] 42 | $Workload, 43 | 44 | [parameter()] 45 | [ValidateSet('Ongoing', 'Resolved')] 46 | [string]$Status, 47 | 48 | [Parameter()] 49 | [switch] 50 | $SendEmail, 51 | 52 | [Parameter()] 53 | [string] 54 | $From, 55 | 56 | [Parameter()] 57 | [string[]] 58 | $To, 59 | 60 | [Parameter()] 61 | [string[]] 62 | $CC, 63 | 64 | [Parameter()] 65 | [string[]] 66 | $Bcc, 67 | 68 | [Parameter()] 69 | [boolean] 70 | $WriteReportToDisk = $true, 71 | 72 | [Parameter()] 73 | [boolean] 74 | $WriteRawJSONToDisk = $false, 75 | 76 | [Parameter()] 77 | [boolean] 78 | $Consolidate = $true, 79 | 80 | [Parameter()] 81 | [boolean] 82 | $SendTeams = $false, 83 | 84 | [Parameter()] 85 | [string[]] 86 | $TeamsWebHookURL, 87 | 88 | [Parameter()] 89 | [string] 90 | $RunHistoryFile 91 | ) 92 | 93 | if ($StartFromLastRun -and $LastUpdatedTime) { 94 | SayWarning "Do not use -StartFromLastRun and -LastUpdatedTime at the same time." 95 | return $null 96 | } 97 | 98 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 99 | $moduleInfo = Get-Module $($MyInvocation.MyCommand.ModuleName) 100 | 101 | $errorFlag = $false 102 | 103 | # $WriteReportToDisk = $true 104 | # $WriteRawJSONToDisk = $true 105 | 106 | $now = Get-Date 107 | 108 | #Region Prepare Output Directory 109 | $outputDir = ([System.IO.Path]::Combine([environment]::getfolderpath('userprofile'), $($moduleInfo.Name), $($TenantID))) 110 | 111 | if (!(Test-Path -Path $outputDir)) { 112 | $null = New-Item -ItemType Directory -Path $outputDir -Force 113 | } 114 | 115 | else { 116 | [System.Collections.ArrayList]$excludeFiles = @('runHistory.csv') 117 | if ($RunHistoryFile) { 118 | $null = $excludeFiles.Add($(Split-Path $RunHistoryFile -Leaf)) 119 | } 120 | Remove-Item -Path $outputDir\* -Exclude $excludeFiles -Force -Confirm:$false 121 | } 122 | SayInfo "Output Directory: $outputDir" 123 | 124 | #EndRegion 125 | 126 | # Set the run times history file if it isn't specified. 127 | if (!($RunHistoryFile)) { 128 | $runHistoryFile = ([System.IO.Path]::Combine($outputDir, "runHistory.csv" )) 129 | } 130 | 131 | 132 | 133 | # Create the history file if it doesn't exist. 134 | if (!(Test-Path $RunHistoryFile) -or !(Get-Content $RunHistoryFile -Raw -ErrorAction SilentlyContinue)) { 135 | SayInfo "Creating $RunHistoryFile" 136 | "RunTime,Status" | Set-Content -Path $RunHistoryFile -Force -Confirm:$false 137 | # Add initial entry 'OK' (which means successful) dated 7 days ago. This way there will always be a starting point. 138 | "$("{0:yyyy-MM-dd H:mm}" -f $now.AddDays(-7)),OK" | Add-Content -Path $RunHistoryFile -Force -Confirm:$false 139 | } 140 | 141 | $RunHistoryFile = (Resolve-Path $RunHistoryFile).Path 142 | SayInfo "History File = $($RunHistoryFile)" 143 | 144 | if (!$OrganizationName) { $OrganizationName = $TenantID } 145 | 146 | SayInfo "Authentication type: $($pscmdlet.ParameterSetName)" 147 | SayInfo "Client ID: $ClientID" 148 | SayInfo "Tenant ID: $TenantID" 149 | 150 | # Get Service Communications API Token 151 | if ($pscmdlet.ParameterSetName -eq 'Client Secret') { 152 | $SecureClientSecret = New-Object System.Security.SecureString 153 | $ClientSecret.toCharArray() | ForEach-Object { $SecureClientSecret.AppendChar($_) } 154 | $OAuth = Get-MsalToken -ClientId $ClientID -ClientSecret $SecureClientSecret -TenantId $tenantID -ErrorAction Stop 155 | SayInfo $($ClientSecret -replace $($ClientSecret.Substring(0, $ClientSecret.Length - 8)), $('X' * $($ClientSecret.Substring(0, $ClientSecret.Length - 8)).Length)) 156 | } 157 | elseif ($pscmdlet.ParameterSetName -eq 'Client Certificate') { 158 | $OAuth = Get-MsalToken -ClientId $ClientID -ClientCertificate $ClientCertificate -TenantId $tenantID -ErrorAction Stop 159 | } 160 | elseif ($pscmdlet.ParameterSetName -eq 'Certificate Thumbprint') { 161 | if (Test-Path Cert:\CurrentUser\My\$($ClientCertificateThumbprint)) { 162 | $ClientCertificate = Get-Item Cert:\CurrentUser\My\$($ClientCertificateThumbprint) -ErrorAction Stop 163 | } 164 | elseif (Test-Path Cert:\LocalMachine\My\$($ClientCertificateThumbprint)) { 165 | $ClientCertificate = Get-Item Cert:\LocalMachine\My\$($ClientCertificateThumbprint) -ErrorAction Stop 166 | } 167 | else { 168 | SayError $_.Exception.Message 169 | return $null 170 | } 171 | $OAuth = Get-MsalToken -ClientId $ClientID -ClientCertificate $ClientCertificate -TenantId $tenantID -ErrorAction Stop 172 | } 173 | 174 | $GraphAPIHeader = @{'Authorization' = "Bearer $($OAuth.AccessToken)" } 175 | 176 | # Get GraphAPI Token 177 | if ($SendEmail) { 178 | if (!$From) { SayWarning "You ask me to send an email report but you forgot to add the -From address."; return $null } 179 | if (!$To) { SayWarning "You ask me to send an email report but you forgot to add the -To address(es)."; return $null } 180 | } 181 | 182 | #Region Get Incidents 183 | $searchParam = @{ 184 | Token = ($OAuth.AccessToken) 185 | } 186 | 187 | if ($Status) { 188 | $searchParam += (@{Status = $Status }) 189 | } 190 | 191 | ## If -StartFromLastRun, this function will only get the incidents whose LastUpdatedTime is after the timestamp in "$outputDir\runHistory.csv" 192 | if ($StartFromLastRun) { 193 | SayInfo "Getting last successful run time from $RunHistoryFile." 194 | [datetime]$LastUpdatedTime = @(Import-Csv $RunHistoryFile | Where-Object { $_.Status -eq 'Ok' })[-1].RunTime 195 | } 196 | 197 | ## If -LastUpdatedTime, this function will only get the incidents whose LastUpdatedTime is after the $LastUpdatedTime datetime value. 198 | if ($LastUpdatedTime) { 199 | $searchParam += (@{LastUpdatedTime = $LastUpdatedTime }) 200 | SayInfo "Getting incidents from the last successful run time (with results): $LastUpdatedTime" 201 | } 202 | 203 | if ($Workload) { 204 | $searchParam += (@{Workload = $Workload }) 205 | SayInfo "Workload: $($Workload -join ',')" 206 | } 207 | try { 208 | $events = @(Get-MS365Messages @searchParam -ErrorAction STOP) 209 | SayInfo "Total Incidents Retrieved: $($events.Count)" 210 | } 211 | catch { 212 | SayError "Failed to get data. $($_.Exception.Message)" 213 | $errorFlag = $true 214 | return $null 215 | } 216 | 217 | #EndRegion 218 | 219 | #Region Create Report 220 | ## Get the CSS style 221 | $css_string = Get-Content (($moduleInfo.ModuleBase.ToString()) + '\source\private\style.css') -Raw 222 | 223 | #Region Consolidate Email 224 | if ($Consolidate) { 225 | if ($events.Count -gt 0) { 226 | $mailSubject = "[$($organizationName)] Microsoft 365 Service Health Report" 227 | $event_id_file = "$outputDir\consolidated_report.html" 228 | $event_id_json_file = "$outputDir\consolidated_report.json" 229 | $htmlBody = [System.Collections.ArrayList]@() 230 | $null = $htmlBody.Add("$($mailSubject)") 231 | $null = $htmlBody.Add('") 234 | $null = $htmlBody.Add("") 235 | $null = $htmlBody.Add("
") 236 | $null = $htmlBody.Add('
Summary
') 237 | $null = $htmlBody.Add("
") 238 | $null = $htmlBody.Add('') 239 | $null = $htmlBody.Add("") 240 | foreach ($event in ($events | Sort-Object Classification -Descending)) { 241 | $ticket_status = ($event.Status.substring(0, 1).toupper() + $event.Status.substring(1) -creplace '[^\p{Ll}\s]', ' $&').Trim() 242 | $null = $htmlBody.Add(" 243 | 244 | 245 | 246 | ") 247 | } 248 | $null = $htmlBody.Add('
WorkloadEvent IDClassificationStatusTitle
$($event.Service)" + '' + "$($event.ID)$($event.Classification.substring(0, 1).toupper() + $event.Classification.substring(1))$($ticket_status)$($event.Title)
') 249 | 250 | foreach ($event in $events | Sort-Object Classification -Descending) { 251 | $ticket_status = ($event.Status.substring(0, 1).toupper() + $event.Status.substring(1) -creplace '[^\p{Ll}\s]', ' $&').Trim() 252 | $null = $htmlBody.Add("
") 253 | $null = $htmlBody.Add('
' + $event.ID + ' | ' + $event.Service + ' | ' + $event.Title + '
') 254 | $null = $htmlBody.Add("
") 255 | $null = $htmlBody.Add('') 256 | $null = $htmlBody.Add('') 257 | $null = $htmlBody.Add('') 258 | $null = $htmlBody.Add('') 259 | $null = $htmlBody.Add('') 260 | $null = $htmlBody.Add('') 261 | $null = $htmlBody.Add('') 262 | $null = $htmlBody.Add('') 270 | 271 | $latestMessage = ($event.posts[-1].description.content) -replace "`n", "
" 272 | 273 | $null = $htmlBody.Add('') 274 | $null = $htmlBody.Add('
Status' + $ticket_status + '
Organization' + $organizationName + '
Classification' + $($event.Classification.substring(0, 1).toupper() + $event.Classification.substring(1)) + '
User Impact' + $event.ImpactDescription + '
Last Updated' + "{0:yyyy-MM-dd H:mm}" -f [datetime]$event.lastModifiedDateTime + '
Start Time' + "{0:yyyy-MM-dd H:mm}" -f [datetime]$event.startDateTime + '
End Time' + $( 263 | if ($event.endDateTime) { 264 | "{0:yyyy-MM-dd H:mm}" -f [datetime]$event.endDateTime 265 | } 266 | else { 267 | $null 268 | } 269 | ) + '
Latest Message' + $latestMessage + '
') 275 | $null = $htmlBody.Add('') 276 | } 277 | 278 | $null = $htmlBody.Add('


') 279 | $null = $htmlBody.Add('
') 280 | $null = $htmlBody.Add('' + $moduleInfo.Name.ToString() + ' v' + $moduleInfo.Version.ToString() + '

') 281 | $null = $htmlBody.Add('') 282 | $null = $htmlBody.Add('') 283 | $htmlBody = $htmlBody -join "`n" #convert to multiline string 284 | 285 | $htmlBody = ReplaceSmartCharacter $htmlBody 286 | 287 | if ($WriteReportToDisk -eq $true) { 288 | $htmlBody | Out-File $event_id_file -Force 289 | } 290 | 291 | if ($SendEmail -eq $true) { 292 | # Recipients 293 | $toAddressJSON = @() 294 | $To | ForEach-Object { 295 | $toAddressJSON += @{EmailAddress = @{Address = $_ } } 296 | } 297 | 298 | try { 299 | #message 300 | $mailBody = @{ 301 | message = @{ 302 | subject = $mailSubject 303 | body = @{ 304 | contentType = "HTML" 305 | content = $htmlBody 306 | } 307 | toRecipients = @( 308 | $ToAddressJSON 309 | ) 310 | internetMessageHeaders = @( 311 | @{ 312 | name = "X-Mailer" 313 | value = "MS365HealthReport (junecastillote)" 314 | } 315 | ) 316 | } 317 | } 318 | 319 | ## Add CC recipients if specified 320 | if ($Cc) { 321 | $ccAddressJSON = @() 322 | $Cc | ForEach-Object { 323 | $ccAddressJSON += @{EmailAddress = @{Address = $_ } } 324 | } 325 | $mailBody.Message += @{ccRecipients = $ccAddressJSON } 326 | } 327 | 328 | ## Add BCC recipients if specified 329 | if ($Bcc) { 330 | $BccAddressJSON = @() 331 | $Bcc | ForEach-Object { 332 | $BccAddressJSON += @{EmailAddress = @{Address = $_ } } 333 | } 334 | $mailBody.Message += @{BccRecipients = $BccAddressJSON } 335 | } 336 | 337 | $mailBody = $($mailBody | ConvertTo-Json -Depth 4) 338 | 339 | if ($WriteRawJSONToDisk) { 340 | $mailBody | Out-File $event_id_json_file -Force 341 | } 342 | 343 | ## Send email 344 | # $ServicePoint = [System.Net.ServicePointManager]::FindServicePoint('https://graph.microsoft.com') 345 | SayInfo "Sending Consolidated Alert for $($events.id -join ';')" 346 | $null = Invoke-RestMethod -Method Post -Uri "https://graph.microsoft.com/beta/users/$($From)/sendmail" -Body $mailBody -Headers $GraphAPIHeader -ContentType 'application/json' 347 | # $null = $ServicePoint.CloseConnectionGroup('') 348 | } 349 | catch { 350 | SayInfo "Failed to send Alert for $($events.id -join ';') | $($_.Exception.Message)" 351 | $errorFlag = $true 352 | return $null 353 | } 354 | } 355 | } 356 | } 357 | #EndRegion Consolidate Email 358 | #Region NoConsolidate Email 359 | else { 360 | if ($events.Count -gt 0) { 361 | foreach ($event in ($events | Sort-Object Classification -Descending) ) { 362 | $ticket_status = ($event.Status.substring(0, 1).toupper() + $event.Status.substring(1) -creplace '[^\p{Ll}\s]', ' $&').Trim() 363 | $mailSubject = "[$($organizationName)] MS365 Service Health Report | $($event.id) | $($event.Service)" 364 | $event_id_file = "$outputDir\$($event.ID).html" 365 | $event_id_json_file = "$outputDir\$($event.ID).json" 366 | $htmlBody = [System.Collections.ArrayList]@() 367 | $null = $htmlBody.Add("$($mailSubject)") 368 | $null = $htmlBody.Add('") 371 | $null = $htmlBody.Add("") 372 | $null = $htmlBody.Add("
") 373 | $null = $htmlBody.Add('
' + $event.ID + ' | ' + $event.Service + ' | ' + $event.Title + '
') 374 | $null = $htmlBody.Add("
") 375 | $null = $htmlBody.Add('') 376 | $null = $htmlBody.Add('') 377 | $null = $htmlBody.Add('') 378 | $null = $htmlBody.Add('') 379 | $null = $htmlBody.Add('') 380 | $null = $htmlBody.Add('') 381 | $null = $htmlBody.Add('') 382 | $null = $htmlBody.Add('') 390 | 391 | $latestMessage = ($event.posts[-1].description.content) -replace "`n", "
" 392 | 393 | $null = $htmlBody.Add('') 394 | $null = $htmlBody.Add('
Status' + $ticket_status + '
Organization' + $organizationName + '
Classification' + $($event.Classification.substring(0, 1).toupper() + $event.Classification.substring(1)) + '
User Impact' + $event.ImpactDescription + '
Last Updated' + [datetime]$event.lastModifiedDateTime + '
Start Time' + [datetime]$event.startDateTime + '
End Time' + $( 383 | if ($event.endDateTime) { 384 | [datetime]$event.endDateTime 385 | } 386 | else { 387 | $null 388 | } 389 | ) + '
Latest Message' + $latestMessage + '
') 395 | 396 | $null = $htmlBody.Add('


') 397 | $null = $htmlBody.Add('
') 398 | $null = $htmlBody.Add('' + $moduleInfo.Name.ToString() + ' v' + $moduleInfo.Version.ToString() + '

') 399 | $null = $htmlBody.Add('') 400 | $null = $htmlBody.Add('') 401 | $htmlBody = $htmlBody -join "`n" #convert to multiline string 402 | 403 | $htmlBody = ReplaceSmartCharacter $htmlBody 404 | # $htmlBody | Out-File -FilePath $env:temp 405 | 406 | if ($WriteReportToDisk -eq $true) { 407 | $htmlBody | Out-File $event_id_file -Force 408 | } 409 | 410 | if ($SendEmail -eq $true) { 411 | 412 | # Recipients 413 | $toAddressJSON = @() 414 | $To | ForEach-Object { 415 | $toAddressJSON += @{EmailAddress = @{Address = $_ } } 416 | } 417 | 418 | try { 419 | #message 420 | $mailBody = @{ 421 | message = @{ 422 | subject = $mailSubject 423 | body = @{ 424 | contentType = "HTML" 425 | content = $htmlBody 426 | } 427 | toRecipients = @( 428 | $ToAddressJSON 429 | ) 430 | internetMessageHeaders = @( 431 | @{ 432 | name = "X-Mailer" 433 | value = "MS365HealthReport (junecastillote)" 434 | } 435 | ) 436 | } 437 | } 438 | 439 | ## Add CC recipients if specified 440 | if ($Cc) { 441 | $ccAddressJSON = @() 442 | $Cc | ForEach-Object { 443 | $ccAddressJSON += @{EmailAddress = @{Address = $_ } } 444 | } 445 | $mailBody.Message += @{ccRecipients = $ccAddressJSON } 446 | } 447 | 448 | ## Add BCC recipients if specified 449 | if ($Bcc) { 450 | $BccAddressJSON = @() 451 | $Bcc | ForEach-Object { 452 | $BccAddressJSON += @{EmailAddress = @{Address = $_ } } 453 | } 454 | $mailBody.Message += @{BccRecipients = $BccAddressJSON } 455 | } 456 | 457 | $mailBody = $($mailBody | ConvertTo-Json -Depth 4) 458 | 459 | if ($WriteRawJSONToDisk) { 460 | $mailBody | Out-File $event_id_json_file -Force 461 | } 462 | 463 | ## Send email 464 | # $ServicePoint = [System.Net.ServicePointManager]::FindServicePoint('https://graph.microsoft.com') 465 | SayInfo "Sending Alert for $($event.id)" 466 | $null = Invoke-RestMethod -Method Post -Uri "https://graph.microsoft.com/v1.0/users/$($From)/sendmail" -Body $mailBody -Headers $GraphAPIHeader -ContentType 'application/json' 467 | # $null = $ServicePoint.CloseConnectionGroup('') 468 | 469 | } 470 | catch { 471 | SayInfo "Failed to send Alert for $($event.id) | $($_.Exception.Message)" 472 | $errorFlag = $true 473 | return $null 474 | } 475 | } 476 | } 477 | } 478 | } 479 | #EndRegion NoConsolidate Email 480 | 481 | if ($events.Count -gt 0) { 482 | if ($SendTeams -eq $true -and $TeamsWebHookURL.Count -gt 0) { 483 | #Region Consolidate (Teams) 484 | # if ($Consolidate -eq $true) { 485 | $teamsAdaptiveCard = New-ConsolidatedCard -InputObject $events 486 | SayInfo "Posting Alert to Teams Channel for $($events.id -join ';')" 487 | foreach ($url in $TeamsWebHookURL) { 488 | $Params = @{ 489 | "URI" = $url 490 | "Method" = 'POST' 491 | "Body" = $teamsAdaptiveCard | ConvertTo-Json -Depth 50 492 | # "Body" = $teamsAdaptiveCard 493 | "ContentType" = 'application/json' 494 | } 495 | try { 496 | # $result = Invoke-RestMethod @Params -ErrorAction Stop 497 | Invoke-RestMethod @Params -ErrorAction Stop 498 | } 499 | catch { 500 | $errorFlag = $true 501 | SayError "Failed to post to channel. $($_.Exception.Message)" 502 | 503 | } 504 | } 505 | # } 506 | #EndRegion Consolidate (Teams) 507 | } 508 | 509 | #Region Don't Consolidate (Teams) 510 | if ($SendTeams -eq $true -and $TeamsWebHookURL.Count -gt 0 -and $Consolidate -eq $false) { 511 | 512 | ## TODO: Add per event Teams notification. 513 | } 514 | #EndRegion Don't Consolidate (Teams) 515 | } 516 | 517 | #EndRegion Create Report 518 | 519 | if ($errorFlag) { 520 | SayInfo "Setting last run time (NotOK) in $($runHistoryFile) to $("{0:yyyy-MM-dd HH:mm}" -f $now)" 521 | "$("{0:yyyy-MM-dd H:mm}" -f $now),NotOK" | Add-Content -Path $RunHistoryFile -Force -Confirm:$false 522 | } 523 | elseif ($events.Count -lt 1) { 524 | SayInfo "Setting last run time (NoResult) in $($runHistoryFile) to $("{0:yyyy-MM-dd HH:mm}" -f $now)" 525 | "$("{0:yyyy-MM-dd H:mm}" -f $now),NoResult" | Add-Content -Path $RunHistoryFile -Force -Confirm:$false 526 | } 527 | else { 528 | SayInfo "Setting last run time (OK) in $($runHistoryFile) to $("{0:yyyy-MM-dd HH:mm}" -f $now)" 529 | "$("{0:yyyy-MM-dd H:mm}" -f $now),OK" | Add-Content -Path $RunHistoryFile -Force -Confirm:$false 530 | } 531 | } -------------------------------------------------------------------------------- /source/public/ReplaceSmartCharacter.ps1: -------------------------------------------------------------------------------- 1 | Function ReplaceSmartCharacter { 2 | #https://4sysops.com/archives/dealing-with-smart-quotes-in-powershell/ 3 | param( 4 | [parameter(Mandatory)] 5 | [string]$String 6 | ) 7 | 8 | # Unicode Quote Characters 9 | $unicodePattern = @{ 10 | '[\u2019\u2018]' = "'" # Single quote 11 | '[\u201C\u201D]' = '"' # Double quote 12 | '\u00A0|\u1680|\u180E|\u2000|\u2001|\u2002|\u2003|\u2004|\u2005|\u2006|\u2007|\u2008|\u2009|\u200A|\u200B|\u202F|\u205F|\u3000|\uFEFF' = " " # Space 13 | '\u0027' = "'" # Apostrophe 14 | } 15 | 16 | $unicodePattern.Keys | ForEach-Object { 17 | $stringToReplace = $_ 18 | $String = $String -replace $stringToReplace, $unicodePattern[$stringToReplace] 19 | } 20 | 21 | return $String 22 | } -------------------------------------------------------------------------------- /source/public/Set-MS365HealthReportLastRunTime.ps1: -------------------------------------------------------------------------------- 1 | Function Set-MS365HealthReportLastRunTime { 2 | [CmdletBinding()] 3 | param ( 4 | [parameter(Mandatory)] 5 | [ValidateNotNullOrEmpty()] 6 | [string]$TenantID, 7 | 8 | [parameter()] 9 | [datetime]$LastRunTime 10 | ) 11 | $now = Get-Date 12 | $RegPath = "HKCU:\Software\MS365HealthReport\$TenantID" 13 | 14 | $regSplat = @{ 15 | Path = $RegPath 16 | Value = $( 17 | if ($LastRunTime) { 18 | "{0:yyyy-MM-dd H:mm}" -f $LastRunTime 19 | } 20 | else { 21 | $now 22 | } 23 | ) 24 | } 25 | $null = New-Item @regSplat -Force 26 | } --------------------------------------------------------------------------------