🔔

Outlookのバッジ通知をPowerShellでカスタマイズする

2023/04/16に公開

Outlookの未読メール数をフォルダごとに通知するアプリをPowerShellで作成しました。その作り方をご紹介します。

作成したアプリはこちら。
https://github.com/mdgrs-mei/outlook-taskbar-notifier

本記事の英語版はこちら。
https://mdgrs.hashnode.dev/how-i-customized-outlook-notifications-with-powershell

フォルダ分けすると封筒マークが表示されない

デスクトップ版Outlookのメール通知に、デスクトップバナー通知を使っている方はおそらく多いのではないでしょうか。デスクトップ通知は気づきやすく便利なのですが、ポップアップした時に内容を読んでしまい、個人的には少し気が散ってしまう印象がありました。そこでデスクトップ通知をOFFにして、このタスクバーの封筒マークの通知を代わりに使ってみることにしました。バナーより控えめでいい感じです。

ところがこの封筒マーク、デフォルトの受信箱にメールが届いたときにしか表示されません。仕分けルールでフォルダ分けされると、未読メールに気がづきません。フォルダ通知ぐらいなら自分で作れるのでは?ということで試作を始めました。

なぜPowerShell?

PowerShellは.NetオブジェクトやCOMオブジェクトを作成して扱うこともできます。これらのオブジェクトの挙動をターミナル上でインタラクティブにテストし、うまく動いたらスクリプト化していくということが気軽にできるので個人的には重宝しています。

OutlookのデスクトップアプリをCOMオブジェクト経由で操作できることを知り、初めはこのCOMオブジェクトの挙動をPowerShellでテストしていました。徐々にテストコードが増えていき、特に障壁もなかったためそのまま最後まで作ってしまった、というのが経緯です。今回の理由としてはそれだけでけなので、本来はC#などで書くほうが向いていると思います。

フォルダの未読メール数を取得する

PowerShellを開いて次の3行をペーストして実行してみてください。デスクトップ版Outlookがインストールされていれば、ルートのフォルダがリストされるはずです。

$outlook = New-Object -ComObject Outlook.Application
$namespace = $outlook.GetNamespace('MAPI')
$namespace.Folders

Folderオブジェクトはそのサブフォルダをプロパティとして保持しているので、目的のフォルダを再帰的に検索することができます。FolderオブジェクトはそれぞれユニークなFolderPathプロパティ(\\your-email-address@sample.com\folder-nameのような値を持ちます)を持っているのでそちらで識別することができます。

$folderPath = '\\your-email-address@sample.com\folder-name'
$rootFolders = $namespace.Folders
foreach ($rootFolder in $rootFolders) {
    $subFolders = $rootFolder.Folders
    foreach ($subFolder in $subFolders) {
        if ($subFolder.FolderPath -eq $folderPath) {
            # 目的のフォルダ
            $subFolder
            return
        }
    }
}

メールはFolderオブジェクトのItemsプロパティに格納されています。ここから未読のメールをフィルタするために、ItemsプロパティのRestrictメソッドを使用します。

$unreadItems = $folder.Items.Restrict('[UnRead] = True')
$unreadItems.Count

他にどのようなフィルタ処理ができるかはオフィシャルのドキュメントを参照してみてください。

タスクバーアイコン

未読メールの数はこれで取得できました。ここから、フォルダごとにタスクバーアイコンを作成し、バッジで未読数を通知できるようにします。タスクバーアイコンを表示するにはアプリケーションのウィンドウが必要です。PowerShellでWPFウィンドウを作ってみましょう。

このアプリのウィンドウには何もUI要素が必要ないので、xamlファイルはこのような最低限のものにします。

<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="320" Height="180"
AllowsTransparency="True"
Background="Transparent"
WindowStyle="None">
</Window>

xamlファイルを作成したら、これをロードしてウィンドウを生成します。

Add-Type -AssemblyName PresentationFramework
$xaml = [xml](Get-Content $xamlFilePath)
$nodeReader = New-Object System.Xml.XmlNodeReader $xaml
$window = [System.Windows.Markup.XamlReader]::Load($nodeReader)
$window.ShowDialog()

これでウィンドウが作成されアイコンも表示されますが、デフォルトではPowerShellのアイコンが使用されてしまいます。

taskbar icon

アイコンをカスタマイズするにはショートカットを作成してアイコンファイルを指定する方法があります。powershell.exe(またはpwsh.exe)へのショートカットを作成し、今回作成したスクリプトを実行するよう引数を指定します。コンソールを非表示にするため、-WindowStyle Hiddenも引数に追加しておきます。このショートカットもせっかくなのでPowerShellで作成します。

$shell = New-Object -ComObject WScript.Shell
$shortcut = $shell.CreateShortcut('D:\out.lnk')
$shortcut.TargetPath = 'powershell.exe'
$shortcut.Arguments = '-ExecutionPolicy Bypass -WindowStyle Hidden -File "D:\script.ps1"'
$shortcut.WindowStyle = 7 # Minimized
$shortcut.IconLocation = 'D:\sample.ico, 0'
$shortcut.Save()

作ったショートカットを実行するとこうなります。

new taskbar icon

バッジカウンター

タスクバーアイコンのバッジはTaskbarItemInfoクラスのOverlayプロパティでアクセスします。まず、xamlファイルにTaskbarItemInfoを追加します。

<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="320" Height="180"
AllowsTransparency="True"
Background="Transparent"
WindowStyle="None">
    <Window.TaskbarItemInfo>
        <TaskbarItemInfo />
    </Window.TaskbarItemInfo>
</Window>

PowerShellからはこのようにアクセスします。

$iconSize = 20
$dpi = 96
$bitmap = New-Object System.Windows.Media.Imaging.RenderTargetBitmap($iconSize, $iconSize, $dpi, $dpi, [System.Windows.Media.PixelFormats]::Default)
# ここで未読数をレンダーする...
$window.TaskbarItemInfo.Overlay = $bitmap

このコードでは透明なビットマップを設定しているだけなので、まだ何も表示されません。このビットマップに未読数をレンダリングしていきます。未読数のバッジは、固定の背景と動的に変化する数字で構成されています。背景とレイアウトを含むテンプレートに数値を入力して、最終的なUI要素を生成したいということになります。WPFはこのような用途のためにContentControlDataTemplateを提供しています。DataTemplateContentControl.ContentTemplateプロパティに指定されると、ContentControlはそのContentにテンプレートを適用してUIを構築します。

まず、DataTemplateをxamlファイルに定義します。

<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Width="320" Height="180"
AllowsTransparency="True"
Background="Transparent"
WindowStyle="None">
    <Window.TaskbarItemInfo>
        <TaskbarItemInfo />
    </Window.TaskbarItemInfo>
    <Window.Resources>
        <DataTemplate x:Key="OverlayIcon">
            <Grid Width="20" Height="20">
                <Rectangle Fill="DeepPink"
                            Stroke="White"
                            StrokeThickness="1"
                            RadiusX="4"
                            RadiusY="4"/>

                <TextBlock Text="{Binding Path=Text}"
                            TextAlignment="Center"
                            VerticalAlignment="Center"
                            Foreground="White"
                            FontWeight="Bold"
                            Height="20"
                            FontSize="14">
                </TextBlock>
            </Grid>
        </DataTemplate>
    </Window.Resources>
</Window>

PowerShell側では以下のようにして、このテンプレートを使いContentControlをレンダリングすることができます。

$unreadCount = 1
$iconSize = 20
$dpi = 96
$bitmap = New-Object System.Windows.Media.Imaging.RenderTargetBitmap($iconSize, $iconSize, $dpi, $dpi, [System.Windows.Media.PixelFormats]::Default)
$rect = New-Object System.Windows.Rect 0, 0, $iconSize, $iconSize

$control = New-Object System.Windows.Controls.ContentControl
$control.ContentTemplate = $window.Resources['OverlayIcon']
$control.Content = [PSCustomObject]@{
    Text = $unreadCount
}
$control.Arrange($rect) # UI要素を配置してサイズを設定する
$bitmap.Render($control)

$window.TaskbarItemInfo.Overlay = $bitmap

xamlファイルの中の{Binding Path=Text}はそれがBinding SourceのTextプロパティにバインドされることを意味しています。ここでのBinding SourceはContentControl.Contentになります。つまり、PSCustomObjectTextプロパティがTextBlock.Textに渡されることになります。

badge counter

かなり近づいてきました😄

タイマー関数

未読数を取得して表示することができるようになりました。あとは、未読数を更新するためにこれを定期的に実行する必要があります。PowerShellにはいくつかのタイマーオブジェクトがありますが、今回はSystem.Windows.Threading.DispatcherTimerを使います。DispathcerTimerは指定された関数をWPFのUIスレッドで定期的に実行します。オーバーレイバッジを含むすべてのUI要素はUIスレッドで実行されなければならないので、このタイマーが今回の用途には適切です。

$intervalInSeconds = 5
$func = {
    $unreadCount = GetUnreadCount
    UpdateIconOverlay $unreadCount
}

$timer = New-Object System.Windows.Threading.DispatcherTimer
$timer.interval = New-Object TimeSpan(0, 0, $intervalInSeconds)
$timer.add_tick($func)
$timer.Start()

クリックしたとき何をするべきか?

タスクバーアイコンをクリックしたとき、通常はウィンドウが開きますが、このアプリについてはそれは望まれる挙動ではありません。ウィンドウを開くことなく、未読メール数が1件の場合はその未読メールを開き、それ以上の場合はOutlookで対象のフォルダを開くのが個人的な理想の挙動です。これはWindowオブジェクトのStateChangedイベントハンドラを以下のように実装することで実現できます。

$window.add_StateChanged({
    if ($window.WindowState -eq [System.Windows.WindowState]::Minimized) {
        return
    }

    $unreadItems = $folder.Items.Restrict('[UnRead] = True')
    if ($unreadItems.Count -eq 1) {
        # 未読メールを表示する
        $unreadItems[1].Display()
    }
    else {
        $explorer = $outlook.ActiveExplorer()
        if ($explorer) {
            # もしOutlookウィンドウがすでに存在すればそれを表示し、フォルダを変更する
            $explorer.Activate()
            $explorer.CurrentFolder = $folder
        } else {
            # Outlookウィンドウが存在しないので新たにExplorerを表示する
            $folder.Display()
        }
    }

    # ウィンドウをすぐに隠す
    $window.WindowState = [System.Windows.WindowState]::Minimized
})

こうなりました。

final result

クリックしたときの挙動は好みがあると思いますが、Outlookオブジェクトを利用して自由にカスタマイズすることができます。

おわりに

OutlookのCOMオブジェクトを使用してフォルダ通知をカスタマイズする方法をご紹介しました。今回作成したものにさらに拡張性を持たせ、アプリとしてこちら
公開しているので、興味がありましたらお試しください。

Discussion